From a60c3459271341f6a6ca2d8c4225623ea2b88856 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Sep 2026 17:37:54 +0200 Subject: [PATCH 001/101] update whats-new --- base/src/main/assets/whats-new.txt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/base/src/main/assets/whats-new.txt b/base/src/main/assets/whats-new.txt index d64c53afb0..75321d592d 100644 --- a/base/src/main/assets/whats-new.txt +++ b/base/src/main/assets/whats-new.txt @@ -1,6 +1,11 @@ -• Display resolution constraint -• Ringer mode constraint -• Set "do not remap" by default in settings -• Bug fixes +• "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 +• Many other bug fixes and improvements 📖 View the complete changelog at: http://keymapper.app/changelog From a7295637e2d725119177005ebea56d38c9c5c6cd Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 13 Sep 2026 17:01:21 +0200 Subject: [PATCH 002/101] read offering metadata from revenuecat --- .../keymapper/purchasing/PurchasingManagerImpl.kt | 3 ++- .../keymapper/base/keymaps/DisplayKeyMapUseCase.kt | 12 +++++++----- .../keymapper/base/purchasing/PurchasingManager.kt | 2 +- .../keymapper/base/purchasing/RevenueCatState.kt | 6 ++++++ 4 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/purchasing/RevenueCatState.kt 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/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/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, +) From c6ca203a0d6e73513f7a591ae42e45681c6889f0 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 13 Sep 2026 20:04:22 +0200 Subject: [PATCH 003/101] bump version to 4.4.1 --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index 5c0a6b7d6c..742238e7d1 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=4.4.0 +VERSION_NAME=4.4.1 VERSION_CODE=264 From 4a632d89b0a3281fec5cce6fee7007cbfa374087 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 14 Sep 2026 11:30:10 +0200 Subject: [PATCH 004/101] fix padding in trigger and action screen --- .../sds100/keymapper/base/actions/ActionsScreen.kt | 1 - .../sds100/keymapper/base/trigger/BaseTriggerScreen.kt | 10 ++-------- .../keymapper/base/trigger/TriggerDiscoverScreen.kt | 10 +++++++++- 3 files changed, 11 insertions(+), 10 deletions(-) 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..9781d71951 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 @@ -155,7 +155,6 @@ private fun ActionsScreen( is State.Data -> Surface(modifier = modifier) { Column { - Spacer(Modifier.height(8.dp)) ActionList( modifier = Modifier.weight(1f), state = state.data, 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..e32b627772 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 @@ -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,8 +246,6 @@ private fun TriggerScreenVertical( } is ConfigTriggerState.Loaded -> { - Spacer(Modifier.height(8.dp)) - tipContent() TriggerList( 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( From 88d6a8c8bfe67f2465a5e4d13bd2bb44b29f0101 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 14 Sep 2026 11:31:04 +0200 Subject: [PATCH 005/101] #1927 feat: promote Key Mapper Gaming PRO in the app --- .../base/home/HomeKeyMapListScreen.kt | 26 ++++++++++--------- .../keymapper/base/home/KeyMapListHeader.kt | 25 ++++++++++-------- .../keymapper/base/home/KeyMapListScreen.kt | 4 +-- .../io/github/sds100/keymapper/data/Keys.kt | 2 ++ 4 files changed, 32 insertions(+), 25 deletions(-) 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..5fa11ec721 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 @@ -84,6 +84,7 @@ fun HomeKeyMapListScreen( modifier: Modifier = Modifier, viewModel: KeyMapListViewModel, snackbarState: SnackbarHostState, + headerExtraContent: @Composable () -> Unit, onSettingsClick: () -> Unit, onAboutClick: () -> Unit, finishActivity: () -> Unit, @@ -196,6 +197,7 @@ fun HomeKeyMapListScreen( KeyMapListHeader( state = state.appBarState, scrollBehavior = scrollBehavior, + extraContent = headerExtraContent, onFixWarningClick = viewModel::onFixWarningClick, onNewGroupClick = viewModel::onNewGroupClick, onGroupClick = viewModel::onGroupClick, @@ -288,7 +290,7 @@ fun HomeKeyMapListScreen( } @Composable -private fun HomeKeyMapListScreen( +fun HomeKeyMapListScreen( modifier: Modifier = Modifier, snackbarState: SnackbarHostState = SnackbarHostState(), appBarContent: @Composable () -> Unit, @@ -448,7 +450,7 @@ private fun BugReportDialog(onDismissRequest: () -> Unit) { } @Composable -private fun sampleList(): List { +fun sameKeyMapListItems(): List { val context = LocalContext.current return listOf( @@ -617,7 +619,7 @@ private fun PreviewSelectingKeyMaps() { showThisGroup = false, ) - val listState = State.Data(sampleList()) + val listState = State.Data(sameKeyMapListItems()) KeyMapperTheme { HomeKeyMapListScreen( @@ -626,7 +628,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 +658,7 @@ private fun PreviewKeyMapsRunning() { isPaused = false, ) - val listState = State.Data(sampleList()) + val listState = State.Data(sameKeyMapListItems()) KeyMapperTheme { HomeKeyMapListScreen( @@ -670,7 +672,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 +695,7 @@ private fun PreviewKeyMapsPaused() { isPaused = true, ) - val listState = State.Data(sampleList()) + val listState = State.Data(sameKeyMapListItems()) KeyMapperTheme { HomeKeyMapListScreen( @@ -707,7 +709,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 +751,7 @@ private fun PreviewKeyMapsWarnings() { isPaused = true, ) - val listState = State.Data(sampleList()) + val listState = State.Data(sameKeyMapListItems()) KeyMapperTheme { HomeKeyMapListScreen( @@ -763,7 +765,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 +813,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 +851,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/KeyMapListHeader.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListHeader.kt index 18ce75a488..20d1b27073 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 @@ -59,6 +59,7 @@ fun KeyMapListHeader( modifier: Modifier = Modifier, state: KeyMapAppBarState, scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), + extraContent: @Composable () -> Unit, onFixWarningClick: (String) -> Unit = {}, onNewGroupClick: () -> Unit = {}, onGroupClick: (String?) -> Unit = {}, @@ -94,6 +95,7 @@ fun KeyMapListHeader( is KeyMapAppBarState.RootGroup -> RootGroupHeader( modifier = modifier.fillMaxWidth(), state = state, + extraContent = extraContent, containerColor = appBarContainerColor, onFixWarningClick = onFixWarningClick, onNewGroupClick = onNewGroupClick, @@ -122,24 +124,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) @@ -348,7 +351,7 @@ private fun RootGroupHeaderPreview() { KeyMapperTheme { Surface { - KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state) + KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state, extraContent = {}) } } } @@ -365,7 +368,7 @@ private fun RootGroupHeaderNoWarningsPreview() { KeyMapperTheme(darkTheme = true) { Surface { - KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state) + KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state, extraContent = {}) } } } @@ -388,7 +391,7 @@ private fun ChildGroupHeaderPreview() { KeyMapperTheme { Surface { - KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state) + KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state, extraContent = {}) } } } @@ -411,7 +414,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/KeyMapListScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt index 89321395b2..4688989732 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 @@ -715,7 +715,7 @@ private fun ListPreview() { KeyMapperTheme { KeyMapList( modifier = Modifier.fillMaxSize(), - listItems = State.Data(sampleList()), + listItems = State.Data(sameKeyMapListItems()), bottomListPadding = 100.dp, ) } @@ -727,7 +727,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/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") } From 1474292dd6c5f182045564c46089603844c674cb Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 14 Sep 2026 11:49:35 +0200 Subject: [PATCH 006/101] fix missing parameter in MainFragment --- app/src/main/java/io/github/sds100/keymapper/MainFragment.kt | 1 + 1 file changed, 1 insertion(+) 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..0cb9797e02 100644 --- a/app/src/main/java/io/github/sds100/keymapper/MainFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/MainFragment.kt @@ -152,6 +152,7 @@ class MainFragment : Fragment() { requireActivity().finish() }, fabBottomPadding = 0.dp, + headerExtraContent = {}, ) } From 70a424712d5599d89eb8a91f8a76faab2ca78697 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 14 Sep 2026 11:49:42 +0200 Subject: [PATCH 007/101] bump version code --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index 742238e7d1..b1b9023e9e 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ VERSION_NAME=4.4.1 -VERSION_CODE=264 +VERSION_CODE=265 From c7dad5a84a82d3412127242c48fda6bd5e99c327 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 14 Sep 2026 23:06:06 +0200 Subject: [PATCH 008/101] fix: bottom sheet Compose previews work again. Your bottom sheet previews stopped rendering because of a Material 3 upgrade. The sheet opens in its own window. I read the 1.4.0 source: ModalBottomSheet now puts its content in a ModalBottomSheetDialog, which on Android is a ComponentDialog, a separate window. Android Studio's preview only draws the preview's own view tree, so it can't draw separate windows. From memory, 1.3.x used a popup that the preview could draw. --- .../base/actions/ActionOptionsBottomSheet.kt | 4 +- .../CreateNotificationActionBottomSheet.kt | 4 +- .../actions/FlashlightActionBottomSheet.kt | 4 +- .../base/actions/HttpRequestBottomSheet.kt | 4 +- .../actions/ModifySettingActionBottomSheet.kt | 4 +- .../base/actions/SmsActionBottomSheet.kt | 4 +- .../actions/StepMediaActionBottomSheet.kt | 4 +- .../base/actions/ToastActionBottomSheet.kt | 4 +- .../base/actions/VolumeActionBottomSheet.kt | 4 +- .../keyevent/FixKeyEventActionBottomSheet.kt | 4 +- .../DisplayResolutionConstraintBottomSheet.kt | 4 +- .../constraints/TimeConstraintBottomSheet.kt | 4 +- .../base/sorting/SortBottomSheetContent.kt | 4 +- .../trigger/TriggerDiscoverBottomSheet.kt | 4 +- .../trigger/TriggerKeyOptionsBottomSheet.kt | 4 +- .../base/trigger/TriggerSetupBottomSheet.kt | 4 +- .../ui/compose/KeyMapperModalBottomSheet.kt | 49 +++++++++++++++++++ 17 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperModalBottomSheet.kt 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..11e1e38c02 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 @@ -22,7 +22,6 @@ 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.Text @@ -42,6 +41,7 @@ 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.KeyMapperModalBottomSheet 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.openUriSafe @@ -56,7 +56,7 @@ fun ActionOptionsBottomSheet( onDismissRequest: () -> Unit = {}, callback: ActionOptionsBottomSheetCallback, ) { - ModalBottomSheet( + KeyMapperModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, 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..b3dbb6027d 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 @@ -14,7 +14,6 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -36,6 +35,7 @@ import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText import io.github.sds100.keymapper.base.utils.ui.compose.filledTonalButtonColorsError import kotlinx.coroutines.launch @@ -116,7 +116,7 @@ private fun CreateNotificationActionBottomSheet( } } - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..f08939fa88 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 @@ -28,7 +28,6 @@ import androidx.compose.material3.FilledTonalIconToggleButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Slider @@ -46,6 +45,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSliderThumb import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText @@ -348,7 +348,7 @@ private fun FlashlightActionBottomSheet( val scrollState = rememberScrollState() val scope = rememberCoroutineScope() - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..50cceb8171 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 @@ -15,7 +15,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -38,6 +37,7 @@ 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.KeyMapperDropdownMenu +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.system.network.HttpMethod import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -110,7 +110,7 @@ private fun HttpRequestBottomSheet( var descriptionError: String? by rememberSaveable { mutableStateOf(null) } var urlError: String? by rememberSaveable { mutableStateOf(null) } - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..452b706415 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 @@ -14,7 +14,6 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -39,6 +38,7 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette import io.github.sds100.keymapper.base.utils.getFullMessage +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow import io.github.sds100.keymapper.base.utils.ui.compose.filledTonalButtonColorsError import io.github.sds100.keymapper.common.utils.KMError @@ -118,7 +118,7 @@ private fun ModifySettingActionBottomSheet( } } - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..2c734df122 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 @@ -16,7 +16,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -41,6 +40,7 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette import io.github.sds100.keymapper.base.utils.getFullMessage +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State @@ -121,7 +121,7 @@ private fun SmsActionBottomSheet( is SmsActionBottomSheetState.ComposeSms -> stringResource(R.string.action_compose_sms) } - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..cb566d215a 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 @@ -12,7 +12,6 @@ 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.Text @@ -28,6 +27,7 @@ import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText import kotlin.math.roundToInt import kotlinx.coroutines.launch @@ -90,7 +90,7 @@ private fun StepMediaActionBottomSheet( else -> R.string.action_step_forward_media } - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..73d4baabe5 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 @@ -12,7 +12,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -33,6 +32,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow import kotlinx.coroutines.launch @@ -84,7 +84,7 @@ private fun ToastActionBottomSheet( } } - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..e60e1e997b 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 @@ -16,7 +16,6 @@ import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Text @@ -37,6 +36,7 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.VolumeStreamStrings import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText import io.github.sds100.keymapper.system.volume.VolumeStream @@ -99,7 +99,7 @@ private fun VolumeActionBottomSheet( val scrollState = rememberScrollState() val scope = rememberCoroutineScope() - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..fb12ae57cf 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 @@ -26,7 +26,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedCard import androidx.compose.material3.RadioButton import androidx.compose.material3.SheetState @@ -51,6 +50,7 @@ import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText import io.github.sds100.keymapper.base.utils.ui.compose.ExpertModeRequirementRow import io.github.sds100.keymapper.base.utils.ui.compose.HeaderText import io.github.sds100.keymapper.base.utils.ui.compose.InputMethodRequirementRow +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.filledTonalButtonColorsError @OptIn(ExperimentalMaterial3Api::class) @@ -69,7 +69,7 @@ fun FixKeyEventActionBottomSheet( onDoneClick: () -> Unit = {}, onAutoSwitchImeCheckedChange: (Boolean) -> Unit = {}, ) { - ModalBottomSheet( + KeyMapperModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, 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..2f48d82fde 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 @@ -14,7 +14,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -31,6 +30,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.common.utils.SizeKM import kotlinx.coroutines.launch @@ -66,7 +66,7 @@ private fun DisplayResolutionConstraintBottomSheet( ) { val scope = rememberCoroutineScope() - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..45831fe2d1 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 @@ -18,7 +18,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Text @@ -41,6 +40,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.common.utils.TimeUtils import java.time.format.FormatStyle @@ -126,7 +126,7 @@ private fun TimeConstraintBottomSheet( ) } - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..231d71ebd4 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 @@ -41,7 +41,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Surface @@ -72,6 +71,7 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette import io.github.sds100.keymapper.base.utils.ui.compose.DraggableItem +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.rememberDragDropState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -120,7 +120,7 @@ private fun SortBottomSheet( ) { val coroutineScope = rememberCoroutineScope() - ModalBottomSheet( + KeyMapperModalBottomSheet( modifier = modifier, sheetState = sheetState, onDismissRequest = onDismissRequest, 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..ae29e617f7 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 @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Text @@ -18,6 +17,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -30,7 +30,7 @@ fun TriggerDiscoverBottomSheet( ) { val scope = rememberCoroutineScope() - ModalBottomSheet( + KeyMapperModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, 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..43f8457efa 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 @@ -19,7 +19,6 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo @@ -42,6 +41,7 @@ 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.KeyMapperModalBottomSheet 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.openUriSafe @@ -66,7 +66,7 @@ fun TriggerKeyOptionsBottomSheet( ) { val isCompact = isVerticalCompactLayout() - ModalBottomSheet( + KeyMapperModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, 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..0092ce0da5 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 @@ -29,7 +29,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Surface @@ -59,6 +58,7 @@ import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText import io.github.sds100.keymapper.base.utils.ui.compose.ExpertModeRequirementRow import io.github.sds100.keymapper.base.utils.ui.compose.HeaderText import io.github.sds100.keymapper.base.utils.ui.compose.InputMethodRequirementRow +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet 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.icons.IndeterminateQuestionBox @@ -838,7 +838,7 @@ fun TriggerSetupBottomSheet( ) { val scope = rememberCoroutineScope() - ModalBottomSheet( + KeyMapperModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperModalBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperModalBottomSheet.kt new file mode 100644 index 0000000000..54fefded1d --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperModalBottomSheet.kt @@ -0,0 +1,49 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Surface +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode + +/** + * Use this instead of [ModalBottomSheet] so the sheet content is visible in previews. + * [ModalBottomSheet] shows its content in a separate dialog window, which previews can't draw. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KeyMapperModalBottomSheet( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + dragHandle: @Composable (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + if (LocalInspectionMode.current) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = BottomSheetDefaults.ExpandedShape, + color = BottomSheetDefaults.ContainerColor, + ) { + Column { + dragHandle?.invoke() + content() + } + } + } else { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + modifier = modifier, + sheetState = sheetState, + dragHandle = dragHandle, + content = content, + ) + } +} From 3e4184e58bb0a4b46d0f1c1609d2a25650eec685 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 14 Sep 2026 23:06:06 +0200 Subject: [PATCH 009/101] fix: bottom sheet Compose previews work again. Your bottom sheet previews stopped rendering because of a Material 3 upgrade. The sheet opens in its own window. I read the 1.4.0 source: ModalBottomSheet now puts its content in a ModalBottomSheetDialog, which on Android is a ComponentDialog, a separate window. Android Studio's preview only draws the preview's own view tree, so it can't draw separate windows. From memory, 1.3.x used a popup that the preview could draw. --- .../base/actions/ActionOptionsBottomSheet.kt | 4 +- .../CreateNotificationActionBottomSheet.kt | 4 +- .../actions/FlashlightActionBottomSheet.kt | 4 +- .../base/actions/HttpRequestBottomSheet.kt | 4 +- .../actions/ModifySettingActionBottomSheet.kt | 4 +- .../base/actions/SmsActionBottomSheet.kt | 4 +- .../actions/StepMediaActionBottomSheet.kt | 4 +- .../base/actions/ToastActionBottomSheet.kt | 4 +- .../base/actions/VolumeActionBottomSheet.kt | 4 +- .../keyevent/FixKeyEventActionBottomSheet.kt | 4 +- .../DisplayResolutionConstraintBottomSheet.kt | 4 +- .../constraints/TimeConstraintBottomSheet.kt | 4 +- .../base/sorting/SortBottomSheetContent.kt | 4 +- .../trigger/TriggerDiscoverBottomSheet.kt | 4 +- .../trigger/TriggerKeyOptionsBottomSheet.kt | 4 +- .../base/trigger/TriggerSetupBottomSheet.kt | 4 +- .../ui/compose/KeyMapperModalBottomSheet.kt | 49 +++++++++++++++++++ 17 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperModalBottomSheet.kt 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..11e1e38c02 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 @@ -22,7 +22,6 @@ 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.Text @@ -42,6 +41,7 @@ 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.KeyMapperModalBottomSheet 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.openUriSafe @@ -56,7 +56,7 @@ fun ActionOptionsBottomSheet( onDismissRequest: () -> Unit = {}, callback: ActionOptionsBottomSheetCallback, ) { - ModalBottomSheet( + KeyMapperModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, 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..b3dbb6027d 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 @@ -14,7 +14,6 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -36,6 +35,7 @@ import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText import io.github.sds100.keymapper.base.utils.ui.compose.filledTonalButtonColorsError import kotlinx.coroutines.launch @@ -116,7 +116,7 @@ private fun CreateNotificationActionBottomSheet( } } - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..f08939fa88 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 @@ -28,7 +28,6 @@ import androidx.compose.material3.FilledTonalIconToggleButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Slider @@ -46,6 +45,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSliderThumb import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText @@ -348,7 +348,7 @@ private fun FlashlightActionBottomSheet( val scrollState = rememberScrollState() val scope = rememberCoroutineScope() - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..50cceb8171 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 @@ -15,7 +15,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -38,6 +37,7 @@ 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.KeyMapperDropdownMenu +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.system.network.HttpMethod import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -110,7 +110,7 @@ private fun HttpRequestBottomSheet( var descriptionError: String? by rememberSaveable { mutableStateOf(null) } var urlError: String? by rememberSaveable { mutableStateOf(null) } - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..452b706415 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 @@ -14,7 +14,6 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -39,6 +38,7 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette import io.github.sds100.keymapper.base.utils.getFullMessage +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow import io.github.sds100.keymapper.base.utils.ui.compose.filledTonalButtonColorsError import io.github.sds100.keymapper.common.utils.KMError @@ -118,7 +118,7 @@ private fun ModifySettingActionBottomSheet( } } - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..2c734df122 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 @@ -16,7 +16,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -41,6 +40,7 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette import io.github.sds100.keymapper.base.utils.getFullMessage +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State @@ -121,7 +121,7 @@ private fun SmsActionBottomSheet( is SmsActionBottomSheetState.ComposeSms -> stringResource(R.string.action_compose_sms) } - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..cb566d215a 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 @@ -12,7 +12,6 @@ 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.Text @@ -28,6 +27,7 @@ import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText import kotlin.math.roundToInt import kotlinx.coroutines.launch @@ -90,7 +90,7 @@ private fun StepMediaActionBottomSheet( else -> R.string.action_step_forward_media } - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..73d4baabe5 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 @@ -12,7 +12,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -33,6 +32,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow import kotlinx.coroutines.launch @@ -84,7 +84,7 @@ private fun ToastActionBottomSheet( } } - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..e60e1e997b 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 @@ -16,7 +16,6 @@ import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Text @@ -37,6 +36,7 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.VolumeStreamStrings import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText import io.github.sds100.keymapper.system.volume.VolumeStream @@ -99,7 +99,7 @@ private fun VolumeActionBottomSheet( val scrollState = rememberScrollState() val scope = rememberCoroutineScope() - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..fb12ae57cf 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 @@ -26,7 +26,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedCard import androidx.compose.material3.RadioButton import androidx.compose.material3.SheetState @@ -51,6 +50,7 @@ import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText import io.github.sds100.keymapper.base.utils.ui.compose.ExpertModeRequirementRow import io.github.sds100.keymapper.base.utils.ui.compose.HeaderText import io.github.sds100.keymapper.base.utils.ui.compose.InputMethodRequirementRow +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.filledTonalButtonColorsError @OptIn(ExperimentalMaterial3Api::class) @@ -69,7 +69,7 @@ fun FixKeyEventActionBottomSheet( onDoneClick: () -> Unit = {}, onAutoSwitchImeCheckedChange: (Boolean) -> Unit = {}, ) { - ModalBottomSheet( + KeyMapperModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, 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..2f48d82fde 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 @@ -14,7 +14,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -31,6 +30,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.common.utils.SizeKM import kotlinx.coroutines.launch @@ -66,7 +66,7 @@ private fun DisplayResolutionConstraintBottomSheet( ) { val scope = rememberCoroutineScope() - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..45831fe2d1 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 @@ -18,7 +18,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Text @@ -41,6 +40,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.common.utils.TimeUtils import java.time.format.FormatStyle @@ -126,7 +126,7 @@ private fun TimeConstraintBottomSheet( ) } - ModalBottomSheet( + KeyMapperModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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..231d71ebd4 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 @@ -41,7 +41,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Surface @@ -72,6 +71,7 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette import io.github.sds100.keymapper.base.utils.ui.compose.DraggableItem +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.rememberDragDropState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -120,7 +120,7 @@ private fun SortBottomSheet( ) { val coroutineScope = rememberCoroutineScope() - ModalBottomSheet( + KeyMapperModalBottomSheet( modifier = modifier, sheetState = sheetState, onDismissRequest = onDismissRequest, 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..ae29e617f7 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 @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Text @@ -18,6 +17,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -30,7 +30,7 @@ fun TriggerDiscoverBottomSheet( ) { val scope = rememberCoroutineScope() - ModalBottomSheet( + KeyMapperModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, 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..43f8457efa 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 @@ -19,7 +19,6 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo @@ -42,6 +41,7 @@ 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.KeyMapperModalBottomSheet 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.openUriSafe @@ -66,7 +66,7 @@ fun TriggerKeyOptionsBottomSheet( ) { val isCompact = isVerticalCompactLayout() - ModalBottomSheet( + KeyMapperModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, 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..0092ce0da5 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 @@ -29,7 +29,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Surface @@ -59,6 +58,7 @@ import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText import io.github.sds100.keymapper.base.utils.ui.compose.ExpertModeRequirementRow import io.github.sds100.keymapper.base.utils.ui.compose.HeaderText import io.github.sds100.keymapper.base.utils.ui.compose.InputMethodRequirementRow +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet 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.icons.IndeterminateQuestionBox @@ -838,7 +838,7 @@ fun TriggerSetupBottomSheet( ) { val scope = rememberCoroutineScope() - ModalBottomSheet( + KeyMapperModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperModalBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperModalBottomSheet.kt new file mode 100644 index 0000000000..54fefded1d --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperModalBottomSheet.kt @@ -0,0 +1,49 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Surface +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode + +/** + * Use this instead of [ModalBottomSheet] so the sheet content is visible in previews. + * [ModalBottomSheet] shows its content in a separate dialog window, which previews can't draw. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KeyMapperModalBottomSheet( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + dragHandle: @Composable (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + if (LocalInspectionMode.current) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = BottomSheetDefaults.ExpandedShape, + color = BottomSheetDefaults.ContainerColor, + ) { + Column { + dragHandle?.invoke() + content() + } + } + } else { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + modifier = modifier, + sheetState = sheetState, + dragHandle = dragHandle, + content = content, + ) + } +} From 2837e3a14a7faf0140b12aafcc5e7e075b1ca820 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 14 Sep 2026 23:12:47 +0200 Subject: [PATCH 010/101] update Compose BOM --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f10514a86a..bb38faaad3 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" From 7771181241b13410e316c3688a8255840d8724d6 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 14 Sep 2026 23:12:47 +0200 Subject: [PATCH 011/101] update Compose BOM --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f10514a86a..bb38faaad3 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" From cc3d807fa2a2974da5f959a61b843410cd170266 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 14 Sep 2026 23:12:47 +0200 Subject: [PATCH 012/101] update Compose BOM --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f10514a86a..bb38faaad3 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" From 8ef90bee8e3345f9e5088dbec452c35d13936e0a Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Sep 2026 10:47:30 +0200 Subject: [PATCH 013/101] Revert "fix: bottom sheet Compose previews work again." This reverts commit c7dad5a84a82d3412127242c48fda6bd5e99c327. --- .../base/actions/ActionOptionsBottomSheet.kt | 4 +- .../CreateNotificationActionBottomSheet.kt | 4 +- .../actions/FlashlightActionBottomSheet.kt | 4 +- .../base/actions/HttpRequestBottomSheet.kt | 4 +- .../actions/ModifySettingActionBottomSheet.kt | 4 +- .../base/actions/SmsActionBottomSheet.kt | 4 +- .../actions/StepMediaActionBottomSheet.kt | 4 +- .../base/actions/ToastActionBottomSheet.kt | 4 +- .../base/actions/VolumeActionBottomSheet.kt | 4 +- .../keyevent/FixKeyEventActionBottomSheet.kt | 4 +- .../DisplayResolutionConstraintBottomSheet.kt | 4 +- .../constraints/TimeConstraintBottomSheet.kt | 4 +- .../base/sorting/SortBottomSheetContent.kt | 4 +- .../trigger/TriggerDiscoverBottomSheet.kt | 4 +- .../trigger/TriggerKeyOptionsBottomSheet.kt | 4 +- .../base/trigger/TriggerSetupBottomSheet.kt | 4 +- .../ui/compose/KeyMapperModalBottomSheet.kt | 49 ------------------- 17 files changed, 32 insertions(+), 81 deletions(-) delete mode 100644 base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperModalBottomSheet.kt 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 11e1e38c02..34fcde0e91 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 @@ -22,6 +22,7 @@ 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.Text @@ -41,7 +42,6 @@ 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.KeyMapperModalBottomSheet 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.openUriSafe @@ -56,7 +56,7 @@ fun ActionOptionsBottomSheet( onDismissRequest: () -> Unit = {}, callback: ActionOptionsBottomSheetCallback, ) { - KeyMapperModalBottomSheet( + ModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, 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 b3dbb6027d..68f74a43ad 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 @@ -14,6 +14,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -35,7 +36,6 @@ import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText -import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText import io.github.sds100.keymapper.base.utils.ui.compose.filledTonalButtonColorsError import kotlinx.coroutines.launch @@ -116,7 +116,7 @@ private fun CreateNotificationActionBottomSheet( } } - KeyMapperModalBottomSheet( + ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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 f08939fa88..d7d058aa61 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 @@ -28,6 +28,7 @@ import androidx.compose.material3.FilledTonalIconToggleButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Slider @@ -45,7 +46,6 @@ 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.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSliderThumb import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText @@ -348,7 +348,7 @@ private fun FlashlightActionBottomSheet( val scrollState = rememberScrollState() val scope = rememberCoroutineScope() - KeyMapperModalBottomSheet( + ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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 50cceb8171..2ebe6e1d51 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 @@ -15,6 +15,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -37,7 +38,6 @@ 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.KeyMapperDropdownMenu -import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.system.network.HttpMethod import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -110,7 +110,7 @@ private fun HttpRequestBottomSheet( var descriptionError: String? by rememberSaveable { mutableStateOf(null) } var urlError: String? by rememberSaveable { mutableStateOf(null) } - KeyMapperModalBottomSheet( + ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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 452b706415..20fe980ed8 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 @@ -14,6 +14,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -38,7 +39,6 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette import io.github.sds100.keymapper.base.utils.getFullMessage -import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow import io.github.sds100.keymapper.base.utils.ui.compose.filledTonalButtonColorsError import io.github.sds100.keymapper.common.utils.KMError @@ -118,7 +118,7 @@ private fun ModifySettingActionBottomSheet( } } - KeyMapperModalBottomSheet( + ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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 2c734df122..b6ad399bb4 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 @@ -16,6 +16,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -40,7 +41,6 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette import io.github.sds100.keymapper.base.utils.getFullMessage -import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State @@ -121,7 +121,7 @@ private fun SmsActionBottomSheet( is SmsActionBottomSheetState.ComposeSms -> stringResource(R.string.action_compose_sms) } - KeyMapperModalBottomSheet( + ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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 cb566d215a..b768f34a1c 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 @@ -12,6 +12,7 @@ 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.Text @@ -27,7 +28,6 @@ import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText -import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText import kotlin.math.roundToInt import kotlinx.coroutines.launch @@ -90,7 +90,7 @@ private fun StepMediaActionBottomSheet( else -> R.string.action_step_forward_media } - KeyMapperModalBottomSheet( + ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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 73d4baabe5..2a0d9a9c56 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 @@ -12,6 +12,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -32,7 +33,6 @@ 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.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow import kotlinx.coroutines.launch @@ -84,7 +84,7 @@ private fun ToastActionBottomSheet( } } - KeyMapperModalBottomSheet( + ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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 e60e1e997b..959951b03c 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 @@ -16,6 +16,7 @@ import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Text @@ -36,7 +37,6 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.VolumeStreamStrings import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText -import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText import io.github.sds100.keymapper.system.volume.VolumeStream @@ -99,7 +99,7 @@ private fun VolumeActionBottomSheet( val scrollState = rememberScrollState() val scope = rememberCoroutineScope() - KeyMapperModalBottomSheet( + ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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 fb12ae57cf..81ca3c2fc1 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 @@ -26,6 +26,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedCard import androidx.compose.material3.RadioButton import androidx.compose.material3.SheetState @@ -50,7 +51,6 @@ import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText import io.github.sds100.keymapper.base.utils.ui.compose.ExpertModeRequirementRow import io.github.sds100.keymapper.base.utils.ui.compose.HeaderText import io.github.sds100.keymapper.base.utils.ui.compose.InputMethodRequirementRow -import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.filledTonalButtonColorsError @OptIn(ExperimentalMaterial3Api::class) @@ -69,7 +69,7 @@ fun FixKeyEventActionBottomSheet( onDoneClick: () -> Unit = {}, onAutoSwitchImeCheckedChange: (Boolean) -> Unit = {}, ) { - KeyMapperModalBottomSheet( + ModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, 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 2f48d82fde..5424ad8c9a 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 @@ -14,6 +14,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -30,7 +31,6 @@ 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.KeyMapperModalBottomSheet import io.github.sds100.keymapper.common.utils.SizeKM import kotlinx.coroutines.launch @@ -66,7 +66,7 @@ private fun DisplayResolutionConstraintBottomSheet( ) { val scope = rememberCoroutineScope() - KeyMapperModalBottomSheet( + ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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 45831fe2d1..e0a1415247 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 @@ -18,6 +18,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Text @@ -40,7 +41,6 @@ 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.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.common.utils.TimeUtils import java.time.format.FormatStyle @@ -126,7 +126,7 @@ private fun TimeConstraintBottomSheet( ) } - KeyMapperModalBottomSheet( + ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, 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 231d71ebd4..fa3e7d9a7a 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 @@ -41,6 +41,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Surface @@ -71,7 +72,6 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette import io.github.sds100.keymapper.base.utils.ui.compose.DraggableItem -import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.rememberDragDropState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -120,7 +120,7 @@ private fun SortBottomSheet( ) { val coroutineScope = rememberCoroutineScope() - KeyMapperModalBottomSheet( + ModalBottomSheet( modifier = modifier, sheetState = sheetState, onDismissRequest = onDismissRequest, 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 ae29e617f7..cf17a0def7 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 @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Text @@ -17,7 +18,6 @@ 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.KeyMapperModalBottomSheet import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -30,7 +30,7 @@ fun TriggerDiscoverBottomSheet( ) { val scope = rememberCoroutineScope() - KeyMapperModalBottomSheet( + ModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, 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 43f8457efa..5a90be8d5c 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 @@ -19,6 +19,7 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo @@ -41,7 +42,6 @@ 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.KeyMapperModalBottomSheet 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.openUriSafe @@ -66,7 +66,7 @@ fun TriggerKeyOptionsBottomSheet( ) { val isCompact = isVerticalCompactLayout() - KeyMapperModalBottomSheet( + ModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, 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 0092ce0da5..1131d4f5a1 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 @@ -29,6 +29,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Surface @@ -58,7 +59,6 @@ import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText import io.github.sds100.keymapper.base.utils.ui.compose.ExpertModeRequirementRow import io.github.sds100.keymapper.base.utils.ui.compose.HeaderText import io.github.sds100.keymapper.base.utils.ui.compose.InputMethodRequirementRow -import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperModalBottomSheet 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.icons.IndeterminateQuestionBox @@ -838,7 +838,7 @@ fun TriggerSetupBottomSheet( ) { val scope = rememberCoroutineScope() - KeyMapperModalBottomSheet( + ModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperModalBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperModalBottomSheet.kt deleted file mode 100644 index 54fefded1d..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperModalBottomSheet.kt +++ /dev/null @@ -1,49 +0,0 @@ -package io.github.sds100.keymapper.base.utils.ui.compose - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SheetState -import androidx.compose.material3.Surface -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalInspectionMode - -/** - * Use this instead of [ModalBottomSheet] so the sheet content is visible in previews. - * [ModalBottomSheet] shows its content in a separate dialog window, which previews can't draw. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun KeyMapperModalBottomSheet( - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - dragHandle: @Composable (() -> Unit)? = null, - content: @Composable ColumnScope.() -> Unit, -) { - if (LocalInspectionMode.current) { - Surface( - modifier = modifier.fillMaxWidth(), - shape = BottomSheetDefaults.ExpandedShape, - color = BottomSheetDefaults.ContainerColor, - ) { - Column { - dragHandle?.invoke() - content() - } - } - } else { - ModalBottomSheet( - onDismissRequest = onDismissRequest, - modifier = modifier, - sheetState = sheetState, - dragHandle = dragHandle, - content = content, - ) - } -} From fc434e148a5b5cbef9838306b8c61daccde237c4 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Sep 2026 11:00:08 +0200 Subject: [PATCH 014/101] fix: show previews for bottom sheets and dialogs by specifying showSystemUi = true in the Preview annotation --- CLAUDE.md | 3 ++ .../base/actions/ActionOptionsBottomSheet.kt | 7 ++- .../CreateNotificationActionBottomSheet.kt | 7 ++- .../actions/FlashlightActionBottomSheet.kt | 13 +++-- .../base/actions/HttpRequestBottomSheet.kt | 7 ++- .../actions/ModifySettingActionBottomSheet.kt | 19 ++++--- .../base/actions/SmsActionBottomSheet.kt | 13 +++-- .../actions/StepMediaActionBottomSheet.kt | 7 ++- .../base/actions/ToastActionBottomSheet.kt | 7 ++- .../base/actions/VolumeActionBottomSheet.kt | 7 ++- .../keyevent/FixKeyEventActionBottomSheet.kt | 10 ++-- .../talkback/PickTalkBackGestureDialog.kt | 2 +- .../DisplayResolutionConstraintBottomSheet.kt | 7 ++- .../constraints/TimeConstraintBottomSheet.kt | 4 +- .../base/expertmode/ExpertModeScreen.kt | 2 +- .../base/home/BackupFilePickerDialog.kt | 4 +- .../keymapper/base/home/ImportDialog.kt | 2 +- .../SetupAccessibilityServiceDialog.kt | 8 +-- .../shortcuts/CreateKeyMapShortcutScreen.kt | 2 +- .../trigger/TriggerDiscoverBottomSheet.kt | 4 +- .../trigger/TriggerKeyOptionsBottomSheet.kt | 16 ++++-- .../base/trigger/TriggerSetupBottomSheet.kt | 49 +++++++++++++------ .../base/utils/ui/UnsavedChangesDialog.kt | 2 +- .../base/utils/ui/compose/SliderOptionText.kt | 2 +- 24 files changed, 138 insertions(+), 66 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 27616596c8..f902d959ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,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/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..f156be2ceb 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 @@ -25,6 +25,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.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope @@ -429,7 +430,7 @@ interface ActionOptionsBottomSheetCallback { } @OptIn(ExperimentalMaterial3Api::class) -@Preview(heightDp = 1000) +@Preview(heightDp = 1000, showSystemUi = true) @Composable private fun Preview() { KeyMapperTheme { @@ -437,6 +438,7 @@ private fun Preview() { skipPartiallyExpanded = true, positionalThreshold = { 0f }, velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, ) ActionOptionsBottomSheet( @@ -489,7 +491,7 @@ private fun Preview() { } @OptIn(ExperimentalMaterial3Api::class) -@Preview +@Preview(showSystemUi = true) @Composable private fun PreviewNoEditButton() { KeyMapperTheme { @@ -497,6 +499,7 @@ private fun PreviewNoEditButton() { skipPartiallyExpanded = true, positionalThreshold = { 0f }, velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, ) ActionOptionsBottomSheet( 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/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..34a683af92 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 @@ -173,7 +174,7 @@ private fun StepMediaActionBottomSheet( } @OptIn(ExperimentalMaterial3Api::class) -@Preview +@Preview(showSystemUi = true) @Composable private fun StepMediaActionBottomSheetPreview() { KeyMapperTheme { @@ -181,6 +182,7 @@ private fun StepMediaActionBottomSheetPreview() { skipPartiallyExpanded = true, positionalThreshold = { 0f }, velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, ) StepMediaActionBottomSheet( @@ -195,7 +197,7 @@ private fun StepMediaActionBottomSheetPreview() { } @OptIn(ExperimentalMaterial3Api::class) -@Preview +@Preview(showSystemUi = true) @Composable private fun StepMediaActionBottomSheetDefaultPreview() { KeyMapperTheme { @@ -203,6 +205,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/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/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/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/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/home/BackupFilePickerDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/BackupFilePickerDialog.kt index 7fdb6b42ae..ac99012d70 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 @@ -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/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/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/shortcuts/CreateKeyMapShortcutScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutScreen.kt index 57dce3fb48..1448d46ea7 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 @@ -438,7 +438,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/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/TriggerKeyOptionsBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt index 5a90be8d5c..24c1b11264 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 @@ -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/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/SliderOptionText.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt index 185f90c1f6..8bb1949548 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 @@ -238,7 +238,7 @@ private fun PreviewDefault() { } } -@Preview +@Preview(showSystemUi = true) @Composable private fun PreviewDialog() { KeyMapperTheme { From 43450981874b89aa01645d10a3cbf7797c0c8641 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Sep 2026 11:00:08 +0200 Subject: [PATCH 015/101] fix: show previews for bottom sheets and dialogs by specifying showSystemUi = true in the Preview annotation --- CLAUDE.md | 3 ++ .../base/actions/ActionOptionsBottomSheet.kt | 7 ++- .../CreateNotificationActionBottomSheet.kt | 7 ++- .../actions/FlashlightActionBottomSheet.kt | 13 +++-- .../base/actions/HttpRequestBottomSheet.kt | 7 ++- .../actions/ModifySettingActionBottomSheet.kt | 19 ++++--- .../base/actions/SmsActionBottomSheet.kt | 13 +++-- .../actions/StepMediaActionBottomSheet.kt | 7 ++- .../base/actions/ToastActionBottomSheet.kt | 7 ++- .../base/actions/VolumeActionBottomSheet.kt | 7 ++- .../keyevent/FixKeyEventActionBottomSheet.kt | 10 ++-- .../talkback/PickTalkBackGestureDialog.kt | 2 +- .../DisplayResolutionConstraintBottomSheet.kt | 7 ++- .../constraints/TimeConstraintBottomSheet.kt | 4 +- .../base/expertmode/ExpertModeScreen.kt | 2 +- .../base/home/BackupFilePickerDialog.kt | 4 +- .../keymapper/base/home/ImportDialog.kt | 2 +- .../SetupAccessibilityServiceDialog.kt | 8 +-- .../shortcuts/CreateKeyMapShortcutScreen.kt | 2 +- .../trigger/TriggerDiscoverBottomSheet.kt | 4 +- .../trigger/TriggerKeyOptionsBottomSheet.kt | 16 ++++-- .../base/trigger/TriggerSetupBottomSheet.kt | 49 +++++++++++++------ .../base/utils/ui/UnsavedChangesDialog.kt | 2 +- .../base/utils/ui/compose/SliderOptionText.kt | 2 +- 24 files changed, 138 insertions(+), 66 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 27616596c8..f902d959ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,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/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..f156be2ceb 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 @@ -25,6 +25,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.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope @@ -429,7 +430,7 @@ interface ActionOptionsBottomSheetCallback { } @OptIn(ExperimentalMaterial3Api::class) -@Preview(heightDp = 1000) +@Preview(heightDp = 1000, showSystemUi = true) @Composable private fun Preview() { KeyMapperTheme { @@ -437,6 +438,7 @@ private fun Preview() { skipPartiallyExpanded = true, positionalThreshold = { 0f }, velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, ) ActionOptionsBottomSheet( @@ -489,7 +491,7 @@ private fun Preview() { } @OptIn(ExperimentalMaterial3Api::class) -@Preview +@Preview(showSystemUi = true) @Composable private fun PreviewNoEditButton() { KeyMapperTheme { @@ -497,6 +499,7 @@ private fun PreviewNoEditButton() { skipPartiallyExpanded = true, positionalThreshold = { 0f }, velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, ) ActionOptionsBottomSheet( 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/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..34a683af92 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 @@ -173,7 +174,7 @@ private fun StepMediaActionBottomSheet( } @OptIn(ExperimentalMaterial3Api::class) -@Preview +@Preview(showSystemUi = true) @Composable private fun StepMediaActionBottomSheetPreview() { KeyMapperTheme { @@ -181,6 +182,7 @@ private fun StepMediaActionBottomSheetPreview() { skipPartiallyExpanded = true, positionalThreshold = { 0f }, velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, ) StepMediaActionBottomSheet( @@ -195,7 +197,7 @@ private fun StepMediaActionBottomSheetPreview() { } @OptIn(ExperimentalMaterial3Api::class) -@Preview +@Preview(showSystemUi = true) @Composable private fun StepMediaActionBottomSheetDefaultPreview() { KeyMapperTheme { @@ -203,6 +205,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/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/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/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/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/home/BackupFilePickerDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/BackupFilePickerDialog.kt index 7fdb6b42ae..ac99012d70 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 @@ -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/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/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/shortcuts/CreateKeyMapShortcutScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutScreen.kt index 57dce3fb48..1448d46ea7 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 @@ -438,7 +438,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/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/TriggerKeyOptionsBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt index 5a90be8d5c..24c1b11264 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 @@ -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/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/SliderOptionText.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt index 185f90c1f6..8bb1949548 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 @@ -238,7 +238,7 @@ private fun PreviewDefault() { } } -@Preview +@Preview(showSystemUi = true) @Composable private fun PreviewDialog() { KeyMapperTheme { From 7e533e5f58ab2a19af3df14b1194d68df110092a Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Sep 2026 11:34:21 +0200 Subject: [PATCH 016/101] #701 feat: implement new design for action options bottom sheet and you can now name actions --- CHANGELOG.md | 10 + .../sds100/keymapper/base/actions/Action.kt | 15 + .../base/actions/ActionOptionsBottomSheet.kt | 809 ++++++++++-------- .../keymapper/base/actions/ActionUiHelper.kt | 20 +- .../base/actions/ConfigActionsUseCase.kt | 13 + .../base/actions/ConfigActionsViewModel.kt | 89 +- .../base/home/KeyMapListItemCreator.kt | 9 +- .../sds100/keymapper/base/keymaps/KeyMap.kt | 9 + .../base/utils/ui/compose/ComposeDialogs.kt | 138 +++ base/src/main/res/values/strings.xml | 26 + .../base/actions/ActionEntityMapperTest.kt | 28 + .../base/actions/ConfigActionsUseCaseTest.kt | 62 ++ .../keymapper/data/entities/ActionEntity.kt | 1 + 13 files changed, 845 insertions(+), 384 deletions(-) create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/actions/ActionEntityMapperTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e5dac9df9c..4aa47a6dd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,19 @@ +## [4.5.0](https://github.com/sds100/KeyMapper/releases/tag/v4.3.2) + +#### TO BE RELEASED + +## Added + +- #701 new action options bottom sheet design. +- #701 you can now give custom names to actions, to make it easier to identify key maps. + ## [4.4.0](https://github.com/sds100/KeyMapper/releases/tag/v4.3.2) #### 12 September 2026 ## Added +- Redesigned the action options with grouped repeat, burst, hold down and delay sections, and actions can now be given a custom name. - #2238 Add a "Reduce app killing" card to Expert Mode on Xiaomi/Redmi/Poco devices with steps to whitelist the app from battery optimisation, disable MIUI optimization, enable autostart, and adjust battery saver settings. - Target Android 17 SDK. - [#2227](https://github.com/keymapperorg/KeyMapper/issues/2227) Add a step to the Expert Mode setup wizard to grant local network access permission, required for ADB on Android 17+. 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..8ee8b43e7e 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,11 @@ 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, ) object ActionEntityMapper { @@ -85,6 +90,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 +108,7 @@ object ActionEntityMapper { holdDownDuration = holdDownDuration, delayBeforeNextAction = delayBeforeNextAction, multiplier = multiplier, + customName = customName, ) } @@ -118,6 +129,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 ) { 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 f156be2ceb..d6a453c6d7 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,32 +9,44 @@ 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.HourglassEmpty +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 @@ -42,9 +54,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 @@ -68,21 +81,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) }, ) { @@ -93,190 +138,330 @@ 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)) + if (state.showDelayBeforeNextAction) { + Spacer(modifier = Modifier.height(16.dp)) + DelayOptions(state = state, callback = callback) + } - Row( - verticalAlignment = Alignment.CenterVertically, + Spacer(modifier = Modifier.height(16.dp)) + + 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) }, + ) - if (state.allowedRepeatModes.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) + 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.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() @@ -293,144 +478,128 @@ fun ActionOptionsBottomSheet( stepSize = SliderStepSizes.ACTION_HOLD_DOWN_DURATION, ) } + } else { + RadioButtonText( + modifier = Modifier + .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) }, + ) - 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( + 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) + .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) }, - defaultValue = state.defaultMultiplier.toFloat(), - value = state.multiplier.toFloat(), - valueText = { "${it.toInt()}x" }, - onValueChange = { callback.onMultiplierChanged(it.toInt()) }, - valueRange = actionMultiplierMin..actionMultiplierMax, - stepSize = SliderStepSizes.ACTION_MULTIPLIER, ) + } + } +} - Spacer(Modifier.height(8.dp)) - - HorizontalDivider() - - Spacer(Modifier.height(8.dp)) - - Row( - 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)) - } - } +@Composable +private fun DelayOptions(state: ActionOptionsState, callback: ActionOptionsBottomSheetCallback) { + Column { + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.HourglassEmpty, + text = stringResource(R.string.action_options_delay_header), + ) - Spacer(Modifier.height(16.dp)) - } + Spacer(modifier = Modifier.height(8.dp)) + + val delayMin = SliderMinimums.DELAY_BEFORE_NEXT_ACTION.toFloat() + val delayMax = 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 = delayMin..delayMax, + stepSize = SliderStepSizes.DELAY_BEFORE_NEXT_ACTION, + ) } } 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, + + showDelayBeforeNextAction = true, + delayBeforeNextAction = 10000, + defaultDelayBeforeNextAction = 5000, + + multiplier = 4, + defaultMultiplier = 1, +) + @OptIn(ExperimentalMaterial3Api::class) -@Preview(heightDp = 1000, showSystemUi = true) +@Preview(heightDp = 1600, showSystemUi = true) @Composable private fun Preview() { KeyMapperTheme { @@ -443,57 +612,16 @@ private fun Preview() { 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(showSystemUi = true) +@Preview(heightDp = 1600, showSystemUi = true) @Composable -private fun PreviewNoEditButton() { +private fun PreviewUntilReleasedUnavailable() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, @@ -504,47 +632,42 @@ private fun PreviewNoEditButton() { 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/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index cebf056bd6..067b9bafba 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 @@ -31,6 +31,14 @@ 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( @@ -712,13 +720,17 @@ 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) @@ -734,7 +746,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, 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..a6b623a275 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 @@ -183,6 +183,12 @@ class ConfigActionsUseCaseImpl @Inject constructor( } } + override fun setActionCustomName(uid: String, customName: String?) { + setActionOption(uid) { action -> + action.copy(customName = customName?.trim()?.takeIf { it.isNotEmpty() }) + } + } + private suspend fun getActionShortcuts(json: String?): List { if (json == null) { return emptyList() @@ -222,6 +228,12 @@ 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) } @@ -269,6 +281,7 @@ interface ConfigActionsUseCase : GetDefaultKeyMapOptionsUseCase { 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 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..709e404bac 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 @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.actions +import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -213,10 +214,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,21 +226,23 @@ 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, + ) } override fun onDelayBeforeNextActionChanged(delay: Int) { @@ -254,18 +253,37 @@ class ConfigActionsViewModel @Inject constructor( 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 + + 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) } } @@ -318,7 +336,7 @@ class ConfigActionsViewModel @Inject constructor( val multiplier = action.multiplier "${multiplier}x ${uiHelper.getTitle(action.data, showDeviceDescriptors)}" } else { - uiHelper.getTitle(action.data, showDeviceDescriptors) + uiHelper.getTitle(action, showDeviceDescriptors) } val icon: ComposeIconInfo = uiHelper.getIcon(action.data) @@ -382,12 +400,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 +427,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,9 +459,6 @@ class ConfigActionsViewModel @Inject constructor( holdDownDuration = action.holdDownDuration ?: defaultHoldDownDuration, defaultHoldDownDuration = defaultHoldDownDuration, - showHoldDownMode = keyMap.isStopHoldingDownActionWhenTriggerPressedAgainAllowed( - action, - ), holdDownMode = if (action.stopHoldDownWhenTriggerPressedAgain) { HoldDownMode.TRIGGER_PRESSED_AGAIN } else { @@ -475,6 +499,10 @@ data class ActionListItemModel( ) data class ActionOptionsState( + val title: String, + val actionTypeTitle: String, + val actionTypeIcon: ImageVector, + val showEditButton: Boolean, val showRepeat: Boolean, @@ -503,7 +531,6 @@ data class ActionOptionsState( val holdDownDuration: Int, val defaultHoldDownDuration: Int, - val showHoldDownMode: Boolean, val holdDownMode: HoldDownMode, val showDelayBeforeNextAction: Boolean, 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..a9f3d57ebd 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 @@ -125,14 +125,9 @@ class KeyMapListItemCreator( 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 { 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..9ea161792b 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 @@ -9,6 +9,8 @@ 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.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 @@ -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) 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..01e09b1707 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 @@ -10,16 +10,130 @@ 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, + /** + * 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 { textFieldValue.text.isBlank() || error != null } } + + 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 + }, + singleLine = true, + maxLines = 1, + isError = isError, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + if (!isError) { + submit() + } + }, + ), + supportingText = { + when { + error != null -> Text(error!!) + + textFieldValue.text.isBlank() -> { + Text(stringResource(R.string.error_cant_be_empty)) + } + } + }, + ) + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -94,3 +208,27 @@ fun CustomDialogContent( } } } + +@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 TextFieldDialogEmptyPreview() { + KeyMapperTheme { + TextFieldDialog( + title = "Title", + submitButtonText = "Submit", + initialText = "", + ) + } +} diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index bb6384e0b9..ead561d3d3 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1808,6 +1808,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 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..ab32e2e5f5 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionEntityMapperTest.kt @@ -0,0 +1,28 @@ +package io.github.sds100.keymapper.base.actions + +import io.github.sds100.keymapper.base.keymaps.KeyMap +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()) + } +} 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..53a6cf3288 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,6 +19,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.nullValue import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -73,6 +76,65 @@ 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()) + } + /** * Issue #852. Add a phone ringing constraint when you add an action * to answer a phone call. 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..ff36035c6e 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 @@ -150,6 +150,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" From 014715c172aeb7e82954a9b8401e3925a7ce1f86 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Sep 2026 14:09:17 +0200 Subject: [PATCH 017/101] update string --- base/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index bb6384e0b9..2e353ee1c3 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1580,7 +1580,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. From 039bf05d9641e35bd1fb8ba16193b5787a5da5e9 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Sep 2026 15:22:15 +0200 Subject: [PATCH 018/101] make title optional in CustomDialog and adjust padding --- .../base/home/BackupFilePickerDialog.kt | 2 +- .../base/utils/ui/compose/ComposeDialogs.kt | 42 ++++++++++--------- 2 files changed, 24 insertions(+), 20 deletions(-) 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 ac99012d70..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, 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..23fca4607f 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,7 +9,6 @@ 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.material3.AlertDialogDefaults import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api @@ -24,10 +23,10 @@ import androidx.compose.ui.unit.dp @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 +39,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 +51,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,7 +78,7 @@ fun CustomDialogContent( color = AlertDialogDefaults.textContentColor, ) } - Spacer(modifier = Modifier.height(16.dp)) + // HorizontalDivider() Box(Modifier.weight(1f, fill = false), content = content) // HorizontalDivider() @@ -85,10 +88,11 @@ fun CustomDialogContent( .fillMaxWidth() .align(Alignment.End) .padding(16.dp), - horizontalArrangement = Arrangement.End, + horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.End), ) { - dismissButton() - Spacer(modifier = Modifier.width(16.dp)) + if (dismissButton != null) { + dismissButton() + } confirmButton() } } From 4db2e86e8f32f560c279a19e3d693a0d2ef84305 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Sep 2026 17:51:28 +0200 Subject: [PATCH 019/101] #2243 feat: redesigned whats new dialog Closes #2243 --- CHANGELOG.md | 10 +- .../github/sds100/keymapper/MainFragment.kt | 10 + base/src/main/assets/whats-new-app.txt | 8 + .../keymapper/base/home/BaseHomeViewModel.kt | 57 ++-- .../base/home/HomeKeyMapListScreen.kt | 5 +- .../keymapper/base/home/KeyMapListAppBar.kt | 16 +- .../keymapper/base/home/WhatsNewDialog.kt | 274 ++++++++++++++++++ .../base/onboarding/OnboardingUseCase.kt | 24 +- .../base/onboarding/WhatsNewNotes.kt | 66 +++++ .../base/utils/ui/compose/CompactChip.kt | 4 + .../base/utils/ui/compose/ComposeDialogs.kt | 4 +- base/src/main/res/values/strings.xml | 8 + .../base/onboarding/WhatsNewParserTest.kt | 93 ++++++ 13 files changed, 530 insertions(+), 49 deletions(-) create mode 100644 base/src/main/assets/whats-new-app.txt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/onboarding/WhatsNewNotes.kt create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/onboarding/WhatsNewParserTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e5dac9df9c..8e3b99839c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ -## [4.4.0](https://github.com/sds100/KeyMapper/releases/tag/v4.3.2) +## [4.4.1](https://github.com/sds100/KeyMapper/releases/tag/v4.4.1) + +#### TO BE RELEASED + +## Added + +- #2243 Redesigned What's New dialog. + +## [4.4.0](https://github.com/sds100/KeyMapper/releases/tag/v4.4.0) #### 12 September 2026 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 0cb9797e02..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,12 +145,19 @@ 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() }, 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..5e8434e331 --- /dev/null +++ b/base/src/main/assets/whats-new-app.txt @@ -0,0 +1,8 @@ ++ Cycle keyboard language action | Switch between your keyboard's languages with a key press ++ Sort key maps by enabled/disabled ++ Custom seek amount for media step actions ++ Export/import key maps on Android TV ++ "Reduce app killing" tips for Xiaomi/Redmi/Poco devices +* Screen tap/swipe/pinch actions now scale to display resolution +- Fix some crashes when auto starting Expert Mode +- Many other bug fixes and improvements 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/HomeKeyMapListScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt index 5fa11ec721..dc04ed1056 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 @@ -87,6 +87,7 @@ fun HomeKeyMapListScreen( headerExtraContent: @Composable () -> Unit, onSettingsClick: () -> Unit, onAboutClick: () -> Unit, + onWhatsNewClick: () -> Unit, finishActivity: () -> Unit, fabBottomPadding: Dp, ) { @@ -161,9 +162,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() @@ -225,7 +224,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)) { 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..6e4745b152 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,8 +29,8 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.automirrored.rounded.HelpOutline import androidx.compose.material.icons.automirrored.rounded.Sort +import androidx.compose.material.icons.outlined.NewReleases import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Done @@ -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,7 @@ fun KeyMapListAppBar( var expandedDropdown by rememberSaveable { mutableStateOf(false) } AppBarActions( - onHelpClick, + onWhatsNewClick, onMenuClick = { expandedDropdown = true }, dropdownMenuContent = { RootGroupDropdownMenu( @@ -246,7 +246,7 @@ fun KeyMapListAppBar( var expandedDropdown by rememberSaveable { mutableStateOf(false) } AppBarActions( - onHelpClick, + onWhatsNewClick, onMenuClick = { expandedDropdown = true }, dropdownMenuContent = { ChildGroupDropdownMenu( @@ -410,15 +410,15 @@ private fun SelectingAppBar( @Composable private fun AppBarActions( - onHelpClick: () -> Unit, + onWhatsNewClick: () -> Unit, onMenuClick: () -> Unit = {}, dropdownMenuContent: @Composable () -> Unit, ) { Row { - IconButton(onClick = onHelpClick) { + IconButton(onClick = onWhatsNewClick) { Icon( - Icons.AutoMirrored.Rounded.HelpOutline, - contentDescription = stringResource(R.string.home_app_bar_help), + Icons.Outlined.NewReleases, + contentDescription = stringResource(R.string.home_app_bar_whats_new), ) } 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..6e327ab851 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt @@ -0,0 +1,274 @@ +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(16.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 = 4.dp, horizontal = 8.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/onboarding/OnboardingUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingUseCase.kt index d29de0b78f..19a595d1d3 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 @@ -40,10 +40,17 @@ class OnboardingUseCaseImpl @Inject constructor( set(Keys.lastInstalledVersionCodeHomeScreen, buildConfigProvider.versionCode) } - override fun getWhatsNewText(): String = - with(fileAdapter.openAsset("whats-new.txt").bufferedReader()) { - readText() - } + // A fresh install has never dismissed the What's New dialog so the key is null. + override val isFirstInstall: Flow = + get(Keys.lastInstalledVersionCodeHomeScreen).map { it == null } + + 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 +58,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 +126,10 @@ interface OnboardingUseCase { val showWhatsNew: Flow fun showedWhatsNew() - fun getWhatsNewText(): String + val isFirstInstall: Flow + val appVersionName: String + + fun getWhatsNewNotes(): WhatsNewNotes val promptForShizukuPermission: Flow 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/utils/ui/compose/CompactChip.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt index 8485de387f..040b285a01 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,6 +31,7 @@ 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, ) { @@ -40,6 +42,7 @@ fun CompactChip( Surface( modifier = modifier.height(chipHeight), color = containerColor, + border = borderStroke, shape = AssistChipDefaults.shape, ) { CompactChipContent(icon, text, contentColor) @@ -48,6 +51,7 @@ fun CompactChip( Surface( modifier = modifier.height(chipHeight), color = containerColor, + border = borderStroke, shape = AssistChipDefaults.shape, onClick = onClick, ) { 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 23fca4607f..4212057655 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 @@ -83,11 +83,13 @@ fun CustomDialogContent( Box(Modifier.weight(1f, fill = false), content = content) // HorizontalDivider() + Spacer(Modifier.height(8.dp)) + Row( modifier = Modifier .fillMaxWidth() .align(Alignment.End) - .padding(16.dp), + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.End), ) { if (dismissButton != null) { diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 2e353ee1c3..bc6ba9d952 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. @@ -1694,6 +1701,7 @@ Sort More Help + What\'s New Select all Deselect all Stop selecting 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) + } +} From 72ab4ad06631975ab5cd98289424e4d17321f187 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Sep 2026 18:39:55 +0200 Subject: [PATCH 020/101] use package manager install and update time to determine whether it is a fresh install --- .../keymapper/base/onboarding/OnboardingUseCase.kt | 12 ++++++++---- .../system/apps/AndroidPackageManagerAdapter.kt | 8 ++++++++ .../keymapper/system/apps/PackageManagerAdapter.kt | 2 ++ 3 files changed, 18 insertions(+), 4 deletions(-) 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 19a595d1d3..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,9 +42,10 @@ class OnboardingUseCaseImpl @Inject constructor( set(Keys.lastInstalledVersionCodeHomeScreen, buildConfigProvider.versionCode) } - // A fresh install has never dismissed the What's New dialog so the key is null. - override val isFirstInstall: Flow = - get(Keys.lastInstalledVersionCodeHomeScreen).map { it == null } + override fun isFreshInstall(): Boolean { + return packageManager.getInstallTime(buildConfigProvider.packageName) == + packageManager.getLastUpdateTime(buildConfigProvider.packageName) + } override val appVersionName: String get() = buildConfigProvider.version @@ -126,9 +129,10 @@ interface OnboardingUseCase { val showWhatsNew: Flow fun showedWhatsNew() - val isFirstInstall: Flow val appVersionName: String + fun isFreshInstall(): Boolean + fun getWhatsNewNotes(): WhatsNewNotes val promptForShizukuPermission: Flow 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) From 1f9fa58639d2a31caf43416bd4bd197e59710ff5 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Sep 2026 21:40:15 +0200 Subject: [PATCH 021/101] #701 feat: actions screen is overhauled --- CHANGELOG.md | 5 +- .../sds100/keymapper/base/actions/Action.kt | 10 + .../keymapper/base/actions/ActionListItem.kt | 634 ++++++++++++------ .../base/actions/ActionListItemModel.kt | 37 + .../base/actions/ActionOptionsBottomSheet.kt | 39 -- .../base/actions/ActionOptionsState.kt | 42 ++ .../keymapper/base/actions/ActionUiHelper.kt | 144 ++-- .../keymapper/base/actions/ActionsScreen.kt | 359 ++++++++-- .../base/actions/ConfigActionsUseCase.kt | 5 + .../base/actions/ConfigActionsViewModel.kt | 153 ++--- .../base/detection/DetectKeyMapsUseCase.kt | 5 +- .../base/home/KeyMapListItemCreator.kt | 12 +- .../keymapper/base/home/KeyMapListScreen.kt | 11 +- .../base/utils/ui/compose/CompactChip.kt | 2 + .../base/utils/ui/compose/ComposeChipModel.kt | 4 + .../base/utils/ui/compose/ComposeDialogs.kt | 1 - .../base/utils/ui/compose/SliderOptionText.kt | 40 +- base/src/main/res/values/strings.xml | 13 +- .../base/actions/ActionEntityMapperTest.kt | 29 + .../base/actions/ConfigActionsUseCaseTest.kt | 18 + .../base/keymaps/DetectKeyMapsUseCaseTest.kt | 29 + .../keymapper/data/entities/ActionEntity.kt | 6 + 22 files changed, 1116 insertions(+), 482 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItemModel.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsState.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2e9e5fed..7579280dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## [4.5.0](https://github.com/sds100/KeyMapper/releases/tag/v4.3.2) +## [4.5.0](https://github.com/sds100/KeyMapper/releases/tag/v4.5.0) #### TO BE RELEASED @@ -6,6 +6,9 @@ - #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. +- #701 actions can be turned off individually without deleting them. +- #701 the delay before the next action is now set with a button between actions instead of in the action options. ## [4.4.1](https://github.com/sds100/KeyMapper/releases/tag/v4.4.1) 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 8ee8b43e7e..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 @@ -34,6 +34,11 @@ data class Action( * 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 { @@ -109,6 +114,7 @@ object ActionEntityMapper { delayBeforeNextAction = delayBeforeNextAction, multiplier = multiplier, customName = customName, + isEnabled = !entity.flags.hasFlag(ActionEntity.ACTION_FLAG_DISABLED), ) } @@ -200,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/ActionListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt index c8493d9604..d7a15df335 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,70 +1,117 @@ package io.github.sds100.keymapper.base.actions +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.animateFloatAsState +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.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.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.material.icons.Icons import androidx.compose.material.icons.outlined.ClearAll +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.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.KeyboardArrowUp +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.Button 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 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.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.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.ComposeIconInfo import io.github.sds100.keymapper.base.utils.ui.compose.DragDropState import io.github.sds100.keymapper.base.utils.ui.drawable +/** + * The Material 3 alpha for disabled content. + */ +private const val DISABLED_ALPHA = 0.38f + +private 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. + */ +private val expandTransition: EnterTransition = + fadeIn(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) + + expandVertically( + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + expandFrom = Alignment.Top, + ) + +private val collapseTransition: ExitTransition = + fadeOut(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) + + shrinkVertically( + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + shrinkTowards = Alignment.Top, + ) + @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 = {}, + onRenameClick: () -> Unit = {}, + onEnabledChange: (Boolean) -> Unit = {}, onMoveUp: (() -> Unit)? = null, onMoveDown: (() -> Unit)? = null, ) { @@ -75,45 +122,54 @@ fun ActionListItem( val moveUpLabel = stringResource(R.string.accessibility_action_move_up) val moveDownLabel = stringResource(R.string.accessibility_action_move_down) - 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 - }, - ) - } + // 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), + ) + + val cardColors = CardDefaults.elevatedCardColors( + containerColor = if (isDragging) { + MaterialTheme.colorScheme.surfaceContainerHighest + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha), + ) + + ElevatedCard( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .semantics { + if (isReorderingEnabled) { + customActions = buildList { + onMoveUp?.let { action -> + add( + CustomAccessibilityAction(moveUpLabel) { + action() + true + }, + ) + } + onMoveDown?.let { action -> + add( + CustomAccessibilityAction(moveDownLabel) { + action() + true + }, + ) } } - }, - colors = CardDefaults.elevatedCardColors( - containerColor = if (isDragging) { - MaterialTheme.colorScheme.surfaceContainerHighest - } else { - MaterialTheme.colorScheme.surfaceContainer - }, - ), - ) { + } + }, + colors = cardColors, + ) { + Column { Row( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxWidth() + .clickable { onExpandedChange(!isExpanded) }, verticalAlignment = Alignment.CenterVertically, ) { Spacer(Modifier.width(8.dp)) @@ -133,210 +189,408 @@ fun ActionListItem( onDragStopped = { dragDropState?.onDragInterrupted() }, ), imageVector = Icons.Rounded.DragHandle, - contentDescription = stringResource(R.string.drag_handle_for, model.text), + contentDescription = stringResource( + R.string.drag_handle_for, + model.title, + ), tint = MaterialTheme.colorScheme.onSurface, ) } 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 + ActionIcon(modifier = Modifier.size(20.dp), icon = model.icon) Spacer(Modifier.width(8.dp)) - TextColumn( + HeaderText( modifier = Modifier .weight(1f) .padding(vertical = 8.dp), - primaryText = primaryText, - secondaryText = model.secondaryText, - errorText = model.error, + model = model, + isExpanded = isExpanded, + onRenameClick = onRenameClick, ) 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), - ) - } - } - - 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 = onEditClick) { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = stringResource(R.string.action_list_item_edit), - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(24.dp), - ) - } - - IconButton(onClick = onRemoveClick) { + IconButton(onClick = { onExpandedChange(!isExpanded) }) { Icon( - imageVector = Icons.Rounded.Clear, - contentDescription = stringResource(R.string.action_list_item_remove), + imageVector = if (isExpanded) { + Icons.Rounded.KeyboardArrowUp + } else { + Icons.Rounded.KeyboardArrowDown + }, + contentDescription = if (isExpanded) { + stringResource(R.string.action_list_item_collapse) + } else { + stringResource(R.string.action_list_item_expand) + }, tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(24.dp), ) } } + + Spacer(Modifier.width(4.dp)) + } + + AnimatedVisibility( + visible = isExpanded, + enter = expandTransition, + exit = collapseTransition, + ) { + ExpandedContent( + modifier = Modifier.padding(start = 16.dp, end = 8.dp, bottom = 8.dp), + model = model, + onEditClick = onEditClick, + onRemoveClick = onRemoveClick, + onFixClick = onFixClick, + onTestClick = onTestClick, + onEnabledChange = onEnabledChange, + ) } } + } +} - 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)) +@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( - imageVector = when (model.linkType) { - LinkType.ARROW -> Icons.Rounded.ArrowDownward - LinkType.PLUS -> Icons.Rounded.Add - LinkType.HIDDEN -> Icons.Rounded.Add - }, + modifier = modifier, + painter = painter, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, + tint = Color.Unspecified, + ) + } + } +} + +@Composable +private fun HeaderText( + modifier: Modifier = Modifier, + model: ActionListItemModel, + isExpanded: Boolean, + onRenameClick: () -> Unit, +) { + Column(modifier = modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( modifier = Modifier - .size(24.dp) - .align(Alignment.CenterHorizontally), + .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, ) - Spacer(Modifier.height(4.dp)) + + if (isExpanded) { + 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 = expandTransition, + exit = collapseTransition, + ) { + Column { + if (model.summary != null) { + Text( + text = model.summary, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + if (model.error != null && model.isEnabled) { + Text( + text = model.error, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } } } } @Composable -private fun TextColumn( +private fun ExpandedContent( modifier: Modifier = Modifier, - primaryText: String, - secondaryText: String? = null, - errorText: String? = null, + model: ActionListItemModel, + onEditClick: () -> Unit, + onRemoveClick: () -> Unit, + onFixClick: () -> Unit, + onTestClick: () -> Unit, + onEnabledChange: (Boolean) -> Unit, ) { Column( modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = primaryText, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + 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)) + + Button( + modifier = Modifier.height(28.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 2.dp), + shape = MaterialTheme.shapes.small, + onClick = onFixClick, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.button_fix)) + } + } + } + } + + 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, + ) + } + + OptionRow( + modifier = Modifier + .fillMaxWidth(), + icon = Icons.Rounded.KeyboardDoubleArrowRight, + text = model.burstText ?: stringResource(R.string.action_list_no_burst), + isSet = model.burstText != null, ) - if (secondaryText != null) { - Text( - text = secondaryText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + + 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, ) } - if (errorText != null) { + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { Text( - text = errorText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, + 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.weight(1f)) + + IconButton(onClick = onTestClick) { + Icon( + imageVector = Icons.Outlined.PlayArrow, + contentDescription = stringResource(R.string.action_list_item_test), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + IconButton(onClick = onEditClick) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(R.string.action_list_item_edit), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + IconButton(onClick = onRemoveClick) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.action_list_item_remove), + tint = MaterialTheme.colorScheme.error, + ) + } } } } +@Composable +private fun OptionRow( + modifier: Modifier = Modifier, + icon: ImageVector, + text: String, + isSet: Boolean, +) { + 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 = text, + style = MaterialTheme.typography.labelSmall, + color = color, + ) + } +} + @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 DragDrawablePreview() { +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, + ) + } +} + +@Preview +@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 +@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..cff9fbb8b0 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItemModel.kt @@ -0,0 +1,37 @@ +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 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 d6a453c6d7..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 @@ -18,7 +18,6 @@ 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.HourglassEmpty import androidx.compose.material.icons.rounded.KeyboardDoubleArrowRight import androidx.compose.material.icons.rounded.Repeat import androidx.compose.material.icons.rounded.TouchApp @@ -178,11 +177,6 @@ fun ActionOptionsBottomSheet( HoldDownOptions(state = state, callback = callback) } - if (state.showDelayBeforeNextAction) { - Spacer(modifier = Modifier.height(16.dp)) - DelayOptions(state = state, callback = callback) - } - Spacer(modifier = Modifier.height(16.dp)) Row( @@ -502,34 +496,6 @@ private fun HoldDownOptions(state: ActionOptionsState, callback: ActionOptionsBo } } -@Composable -private fun DelayOptions(state: ActionOptionsState, callback: ActionOptionsBottomSheetCallback) { - Column { - OptionsHeaderRow( - modifier = Modifier.padding(horizontal = 16.dp), - icon = Icons.Rounded.HourglassEmpty, - text = stringResource(R.string.action_options_delay_header), - ) - - Spacer(modifier = Modifier.height(8.dp)) - - val delayMin = SliderMinimums.DELAY_BEFORE_NEXT_ACTION.toFloat() - val delayMax = 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 = delayMin..delayMax, - stepSize = SliderStepSizes.DELAY_BEFORE_NEXT_ACTION, - ) - } -} - interface ActionOptionsBottomSheetCallback { fun onEditClick() = run { } fun onReplaceClick() = run { } @@ -548,7 +514,6 @@ interface ActionOptionsBottomSheetCallback { */ fun onSelectHoldDownMode(holdDownMode: HoldDownMode?) = run { } fun onHoldDownDurationChanged(duration: Int) = run { } - fun onDelayBeforeNextActionChanged(delay: Int) = run { } fun onMultiplierChanged(multiplier: Int) = run { } } @@ -590,10 +555,6 @@ private val previewState = ActionOptionsState( holdDownMode = HoldDownMode.TRIGGER_RELEASED, - showDelayBeforeNextAction = true, - delayBeforeNextAction = 10000, - defaultDelayBeforeNextAction = 5000, - multiplier = 4, defaultMultiplier = 1, ) 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 067b9bafba..1296d5b774 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 @@ -905,76 +905,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(" ") + + when (action.repeatMode) { + RepeatMode.TRIGGER_RELEASED -> { + append(getString(R.string.flag_repeat_build_description_until_released)) + } - else -> Unit + 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/ActionsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt index 9781d71951..99c392f3f9 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,30 +8,40 @@ 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 import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.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,20 +152,48 @@ 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, + 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() @@ -159,17 +203,10 @@ private fun ActionsScreen( 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( @@ -196,32 +233,44 @@ 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 onActionTipDismiss() = run { } + fun onTipButtonClick(id: String) = run { } + fun onEnabledChange(id: String, enabled: 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() + // A list rather than a set so it can be saved in a Bundle. + var expandedIds by rememberSaveable { mutableStateOf(listOf()) } + val dragDropState = if (state is ConfigActionsState.Loaded) { rememberDragDropState( lazyListState = lazyListState, - onMove = onMove, + onMove = callback::onMove, // Do not drag and drop the row of shortcuts ignoreLastItems = if (state.shortcuts.isEmpty()) { 0 } else { 1 }, + // Collapse all the items so they are a similar height while dragging. + onStart = { expandedIds = emptyList() }, ) } else { null @@ -248,9 +297,9 @@ 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)) @@ -276,35 +325,66 @@ private fun ActionList( key = { _, item -> item.id }, contentType = { _, _ -> "action" }, ) { index, model -> + // Automatically expand the item when an error appears so the user can fix it. + LaunchedEffect(model.id, model.error) { + if (model.isEnabled && model.error != null && model.id !in expandedIds) { + expandedIds = expandedIds + model.id + } + } + DraggableItem( dragDropState = dragDropState!!, index = index, ) { 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) } + Column { + ActionListItem( + modifier = Modifier.fillMaxWidth(), + model = model, + index = index, + isExpanded = model.id in expandedIds, + isDraggingEnabled = state.actions.size > 1, + isDragging = isDragging, + isReorderingEnabled = state.isReorderingEnabled, + dragDropState = dragDropState, + onExpandedChange = { expanded -> + expandedIds = if (expanded) { + expandedIds + model.id + } else { + expandedIds - model.id + } + }, + onEditClick = { callback.onEditClick(model.id) }, + onRemoveClick = { onRemoveClick(model.id) }, + onFixClick = { callback.onFixErrorClick(model.id) }, + onTestClick = { callback.onTestClick(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 < state.actions.size - 1 + ) { + { callback.onMove(index, index + 1) } + } else { + null + }, + ) + + if (model.showDelayChip) { + ActionLinkRow( + delayBeforeNextAction = model.delayBeforeNextAction, + onDelayClick = { onDelayClick(model.id) }, + ) } else { - null - }, - ) + // 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)) + } + } } } } @@ -325,7 +405,7 @@ private fun ActionList( .fillMaxWidth() .padding(horizontal = 32.dp), shortcuts = state.shortcuts, - onClick = { onClickShortcut(it) }, + onClick = { callback.onClickShortcut(it) }, ) } } @@ -333,6 +413,121 @@ private fun ActionList( } } +@Composable +private fun ActionLinkRow( + modifier: Modifier = Modifier, + delayBeforeNextAction: Int?, + onDelayClick: () -> Unit, +) { + 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 = AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + leadingIconContentColor = MaterialTheme.colorScheme.onSurface, + ), + 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() { @@ -391,19 +586,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, + ), + 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", + isEnabled = false, ), ), shortcuts = setOf( @@ -427,3 +628,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/ConfigActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt index a6b623a275..e383a5267c 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 @@ -189,6 +189,10 @@ class ConfigActionsUseCaseImpl @Inject constructor( } } + 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() @@ -282,6 +286,7 @@ interface ConfigActionsUseCase : GetDefaultKeyMapOptionsUseCase { 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 709e404bac..2e56831119 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 @@ -1,6 +1,5 @@ package io.github.sds100.keymapper.base.actions -import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -19,7 +18,6 @@ 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 @@ -54,12 +52,13 @@ class ConfigActionsViewModel @Inject constructor( private val onboardingUseCase: OnboardingUseCase, setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, fixKeyEventActionDelegate: FixKeyEventActionDelegate, - onboardingTipDelegate: OnboardingTipDelegate, + private val onboardingTipDelegate: OnboardingTipDelegate, resourceProvider: ResourceProvider, navigationProvider: NavigationProvider, dialogProvider: DialogProvider, ) : ViewModel(), ActionOptionsBottomSheetCallback, + ActionListCallback, SetupAccessibilityServiceDelegate by setupAccessibilityServiceDelegate, ResourceProvider by resourceProvider, DialogProvider by dialogProvider, @@ -114,13 +113,13 @@ class ConfigActionsViewModel @Inject constructor( return config.keyMap.first().dataOrNull()?.actionList?.singleOrNull { it.uid == uid }?.data } - fun onClickShortcut(action: ActionData) { + override fun onClickShortcut(action: ActionData) { viewModelScope.launch { config.addAction(action) } } - fun onFixError(actionUid: String) { + override fun onFixErrorClick(actionUid: String) { viewModelScope.launch { val actionData = getActionData(actionUid) ?: return@launch val error = @@ -168,7 +167,7 @@ class ConfigActionsViewModel @Inject constructor( } } - fun onMoveAction(fromIndex: Int, toIndex: Int) { + override fun onMove(fromIndex: Int, toIndex: Int) { config.moveAction(fromIndex, toIndex) } @@ -176,11 +175,11 @@ 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) @@ -245,8 +244,20 @@ class ConfigActionsViewModel @Inject constructor( ) } - override fun onDelayBeforeNextActionChanged(delay: Int) { - actionOptionsUid.value?.let { uid -> config.setDelayBeforeNextAction(uid, delay) } + fun onDelayChanged(actionUid: String, delay: Int) { + config.setDelayBeforeNextAction(actionUid, 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) { @@ -275,7 +286,10 @@ class ConfigActionsViewModel @Inject constructor( override fun onCustomNameChanged(name: String) { val uid = actionOptionsUid.value ?: return + onRenameAction(uid, name) + } + fun onRenameAction(uid: String, name: String) { viewModelScope.launch { val actionData = getActionData(uid) ?: return@launch val showDeviceDescriptors = displayAction.showDeviceDescriptors.first() @@ -330,60 +344,36 @@ class ConfigActionsViewModel @Inject constructor( ): 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, 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) - - 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 repeatText = uiHelper.getRepeatDescription(keyMap, action) + val burstText = uiHelper.getBurstDescription(action) + val holdDownText = uiHelper.getHoldDownDescription(keyMap, action) - 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, + 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, ) } } @@ -465,10 +455,6 @@ class ConfigActionsViewModel @Inject constructor( HoldDownMode.TRIGGER_RELEASED }, - showDelayBeforeNextAction = keyMap.isDelayBeforeNextActionAllowed(), - delayBeforeNextAction = action.delayBeforeNextAction ?: 0, - defaultDelayBeforeNextAction = 0, - multiplier = action.multiplier ?: 1, defaultMultiplier = 1, ) @@ -487,56 +473,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 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 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/detection/DetectKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt index 01d28b35b1..7754d823ae 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 @@ -81,7 +81,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, ), ) 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 a9f3d57ebd..f70861d8e3 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 @@ -148,7 +148,7 @@ class KeyMapListItemCreator( append( getString( - R.string.action_title_wait, + R.string.action_title_wait_ms, action.delayBeforeNextAction, ), ) @@ -158,8 +158,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 are never performed so do not show their errors. + val chip = if (error == null || !action.isEnabled) { + ComposeChipModel.Normal( + id = action.uid, + text = chipText, + icon = icon, + isEnabled = action.isEnabled, + ) } else { ComposeChipModel.Error(action.uid, chipText, error, isFixable = error.isFixable) } 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 4688989732..a46e7ef45c 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 @@ -473,20 +473,26 @@ private fun OptionsDescription(modifier: Modifier = Modifier, options: List 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.fillMaxHeight(), + modifier = Modifier + .fillMaxHeight(), painter = rememberDrawablePainter(icon.drawable), contentDescription = null, tint = Color.Unspecified, ) is ComposeIconInfo.Vector -> Icon( - modifier = Modifier.fillMaxHeight(), + modifier = Modifier + .fillMaxHeight(), imageVector = icon.imageVector, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, @@ -653,6 +659,7 @@ private fun sampleList(): List { drawable = context.drawable(R.drawable.ic_launcher_web), ), "Open Key Mapper", + isEnabled = false, ), ), constraintMode = ConstraintMode.AND, 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 040b285a01..3eb294318b 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 @@ -42,6 +42,7 @@ fun CompactChip( Surface( modifier = modifier.height(chipHeight), color = containerColor, + contentColor = contentColor, border = borderStroke, shape = AssistChipDefaults.shape, ) { @@ -51,6 +52,7 @@ fun CompactChip( Surface( modifier = modifier.height(chipHeight), color = containerColor, + contentColor = contentColor, border = borderStroke, shape = AssistChipDefaults.shape, onClick = onClick, 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 ae8118a8ad..10a9cf2f08 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,7 +9,6 @@ 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 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 8bb1949548..9fdfd8433e 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 @@ -44,7 +44,7 @@ import kotlin.math.roundToInt @Composable fun SliderOptionText( modifier: Modifier = Modifier, - title: String, + title: String?, value: Float, defaultValue: Float, valueText: (Float) -> String, @@ -77,13 +77,15 @@ fun SliderOptionText( } 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() } @@ -138,7 +140,7 @@ fun SliderOptionText( private fun ValueDialog( initialValue: Int?, placeholderValue: String?, - title: String, + title: String?, onDismissRequest: () -> Unit, onSaveClick: (Int?) -> Unit, ) { @@ -218,6 +220,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() { diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 0d340c3325..81b4dd3c53 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -70,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 @@ -1802,6 +1803,16 @@ Remove Edit Test + Expand + Collapse + Rename + 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 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 index ab32e2e5f5..7ecd30102e 100644 --- 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 @@ -1,6 +1,8 @@ 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 @@ -25,4 +27,31 @@ class ActionEntityMapperTest { 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/ConfigActionsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt index 53a6cf3288..b33fa2cdf9 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 @@ -135,6 +135,24 @@ class ConfigActionsUseCaseTest { 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)) + } + /** * Issue #852. Add a phone ringing constraint when you add an action * to answer a phone call. 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..45cc078e17 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,5 +1,7 @@ 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.ConstraintMode @@ -211,6 +213,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") 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 ff36035c6e..2ec96727b1 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 @@ -142,6 +142,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" From 14af216ac088a400233365a87bd823148600561a Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Sep 2026 22:07:49 +0200 Subject: [PATCH 022/101] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7579280dc3..83e23a6290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - #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. -- #701 actions can be turned off individually without deleting them. +- #1465 actions can be turned off individually without deleting them. - #701 the delay before the next action is now set with a button between actions instead of in the action options. ## [4.4.1](https://github.com/sds100/KeyMapper/releases/tag/v4.4.1) From b3656908d1da1ca0956cef2a26ce16345dd57d20 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Sep 2026 22:53:38 +0200 Subject: [PATCH 023/101] fix drag and drop of actions in the list --- .../keymapper/base/actions/ActionListItem.kt | 4 +- .../keymapper/base/actions/ActionsScreen.kt | 38 +-- .../base/sorting/SortBottomSheetContent.kt | 18 +- .../base/trigger/BaseTriggerScreen.kt | 20 +- .../base/trigger/TriggerKeyListItem.kt | 4 +- .../base/utils/ui/compose/DragDropState.kt | 313 +++++++++++++----- .../base/utils/ui/compose/DraggableItem.kt | 9 +- 7 files changed, 274 insertions(+), 132 deletions(-) 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 d7a15df335..7ef9564460 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 @@ -183,8 +183,8 @@ fun ActionListItem( enabled = isDraggingEnabled, orientation = Orientation.Vertical, startDragImmediately = true, - onDragStarted = { offset -> - dragDropState?.onDragStart(index, offset) + onDragStarted = { + dragDropState?.onDragStart(model.id) }, onDragStopped = { dragDropState?.onDragInterrupted() }, ), 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 99c392f3f9..08d9e71fe6 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 @@ -37,6 +37,7 @@ import androidx.compose.runtime.LaunchedEffect 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 @@ -259,22 +260,15 @@ private fun ActionList( // A list rather than a set so it can be saved in a Bundle. var expandedIds by rememberSaveable { mutableStateOf(listOf()) } - val dragDropState = if (state is ConfigActionsState.Loaded) { - rememberDragDropState( - lazyListState = lazyListState, - onMove = callback::onMove, - // Do not drag and drop the row of shortcuts - ignoreLastItems = if (state.shortcuts.isEmpty()) { - 0 - } else { - 1 - }, - // Collapse all the items so they are a similar height while dragging. - onStart = { expandedIds = emptyList() }, - ) - } 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. @@ -320,8 +314,10 @@ 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 -> @@ -333,8 +329,8 @@ private fun ActionList( } DraggableItem( - dragDropState = dragDropState!!, - index = index, + dragDropState = dragDropState, + key = model.id, ) { isDragging -> Column { ActionListItem( @@ -342,7 +338,7 @@ private fun ActionList( model = model, index = index, isExpanded = model.id in expandedIds, - isDraggingEnabled = state.actions.size > 1, + isDraggingEnabled = orderedActions.size > 1, isDragging = isDragging, isReorderingEnabled = state.isReorderingEnabled, dragDropState = dragDropState, @@ -365,7 +361,7 @@ private fun ActionList( null }, onMoveDown = if (state.isReorderingEnabled && - index < state.actions.size - 1 + index < orderedActions.size - 1 ) { { callback.onMove(index, index + 1) } } 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/trigger/BaseTriggerScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt index e32b627772..ed8c073118 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 @@ -26,6 +26,7 @@ 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 @@ -437,16 +438,15 @@ private fun TriggerList( onAddMoreClick: () -> 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. @@ -457,19 +457,19 @@ private fun TriggerList( horizontalAlignment = Alignment.CenterHorizontally, ) { 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, @@ -481,7 +481,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 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..adfb51b65f 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 @@ -127,8 +127,8 @@ fun TriggerKeyListItem( enabled = isDraggingEnabled, orientation = Orientation.Vertical, startDragImmediately = true, - onDragStarted = { offset -> - dragDropState?.onDragStart(index, offset) + onDragStarted = { + dragDropState?.onDragStart(model.id) }, onDragStopped = { dragDropState?.onDragInterrupted() }, ), 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..7d05a62527 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,18 +11,21 @@ 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 { From b6982f06a48d7ea9c81576b74258e15be2e34f55 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Sep 2026 23:10:16 +0200 Subject: [PATCH 024/101] fix changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e23a6290..8d53d91a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - #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. - #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. ## [4.4.1](https://github.com/sds100/KeyMapper/releases/tag/v4.4.1) @@ -24,7 +25,6 @@ ## Added -- Redesigned the action options with grouped repeat, burst, hold down and delay sections, and actions can now be given a custom name. - #2238 Add a "Reduce app killing" card to Expert Mode on Xiaomi/Redmi/Poco devices with steps to whitelist the app from battery optimisation, disable MIUI optimization, enable autostart, and adjust battery saver settings. - Target Android 17 SDK. - [#2227](https://github.com/keymapperorg/KeyMapper/issues/2227) Add a step to the Expert Mode setup wizard to grant local network access permission, required for ADB on Android 17+. From 1ce72a984bc4eb6035271fe9120e7e3212b0be7d Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Sep 2026 23:10:41 +0200 Subject: [PATCH 025/101] bump version code --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index b1b9023e9e..a5feef418e 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ VERSION_NAME=4.4.1 -VERSION_CODE=265 +VERSION_CODE=266 From 21cdfab2d7677c092ac516bfe9b2a1f2060bd61b Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Sep 2026 23:12:54 +0200 Subject: [PATCH 026/101] bump version code to 4.5 --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index a5feef418e..2a017b1a03 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=4.4.1 +VERSION_NAME=4.5.0 VERSION_CODE=266 From b4c1cd88c1d27a7e50c16f13e08dfaea25d50cb3 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 17 Sep 2026 10:29:18 +0200 Subject: [PATCH 027/101] fix padding in ActionsScreen and ActionListItem --- .../keymapper/base/actions/ActionListItem.kt | 33 ++++++++++++++++++- .../keymapper/base/actions/ActionsScreen.kt | 6 +++- 2 files changed, 37 insertions(+), 2 deletions(-) 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 7ef9564460..008ce5b957 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 @@ -241,7 +241,12 @@ fun ActionListItem( exit = collapseTransition, ) { ExpandedContent( - modifier = Modifier.padding(start = 16.dp, end = 8.dp, bottom = 8.dp), + modifier = Modifier.padding( + start = 16.dp, + end = 8.dp, + bottom = 8.dp, + top = 8.dp, + ), model = model, onEditClick = onEditClick, onRemoveClick = onRemoveClick, @@ -573,6 +578,32 @@ private fun ExpandedPreview() { } } +@Preview +@Composable +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() { 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 08d9e71fe6..113610c1b0 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 @@ -374,10 +374,12 @@ private fun ActionList( delayBeforeNextAction = model.delayBeforeNextAction, onDelayClick = { onDelayClick(model.id) }, ) - } else { + } 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)) } } @@ -389,6 +391,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, From e613e9d7590104a51c2e8cb7b8c54402ba08f3e6 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 17 Sep 2026 10:42:48 +0200 Subject: [PATCH 028/101] tweak animation of expanding action list item --- .../keymapper/base/actions/ActionListItem.kt | 228 +++++++++++------- .../keymapper/base/actions/ActionsScreen.kt | 4 +- 2 files changed, 147 insertions(+), 85 deletions(-) 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 008ce5b957..f176527a2e 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,14 +1,18 @@ package io.github.sds100.keymapper.base.actions import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.BoundsTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateBounds 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.clickable import androidx.compose.foundation.gestures.Orientation @@ -54,6 +58,7 @@ 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.layout.LookaheadScope import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction @@ -95,6 +100,50 @@ private val collapseTransition: ExitTransition = shrinkTowards = Alignment.Top, ) +/** + * 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, + ) + +private val headerBoundsTransform = BoundsTransform { _, _ -> + tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing) +} + @Composable fun ActionListItem( modifier: Modifier = Modifier, @@ -139,8 +188,6 @@ fun ActionListItem( ElevatedCard( modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) .semantics { if (isReorderingEnabled) { customActions = buildList { @@ -165,95 +212,104 @@ fun ActionListItem( }, colors = cardColors, ) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onExpandedChange(!isExpanded) }, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(Modifier.width(8.dp)) + LookaheadScope { + 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(model.id) - }, - onDragStopped = { dragDropState?.onDragInterrupted() }, + if (isReorderingEnabled) { + Icon( + modifier = Modifier + .size(24.dp) + .draggable( + state = draggableState, + enabled = isDraggingEnabled, + orientation = Orientation.Vertical, + startDragImmediately = true, + onDragStarted = { + dragDropState?.onDragStart(model.id) + }, + onDragStopped = { dragDropState?.onDragInterrupted() }, + ), + imageVector = Icons.Rounded.DragHandle, + contentDescription = stringResource( + R.string.drag_handle_for, + model.title, ), - imageVector = Icons.Rounded.DragHandle, - contentDescription = stringResource( - R.string.drag_handle_for, - model.title, - ), - tint = MaterialTheme.colorScheme.onSurface, - ) - } + tint = MaterialTheme.colorScheme.onSurface, + ) + } - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(8.dp)) - ActionIcon(modifier = Modifier.size(20.dp), icon = model.icon) + ActionIcon(modifier = Modifier.size(20.dp), icon = model.icon) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(8.dp)) - HeaderText( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp), - model = model, - isExpanded = isExpanded, - onRenameClick = onRenameClick, - ) + // The summary shrinks while the rename button grows, so the header's natural + // height dips before reaching its final height. Animate straight to the final + // size instead so the drag handle, icon and chevron do not bounce. + HeaderText( + modifier = Modifier + .weight(1f) + .animateBounds( + lookaheadScope = this@LookaheadScope, + modifier = Modifier.padding(vertical = 8.dp), + boundsTransform = headerBoundsTransform, + ), + model = model, + isExpanded = isExpanded, + onRenameClick = onRenameClick, + ) - CompositionLocalProvider( - LocalMinimumInteractiveComponentSize provides 16.dp, - ) { - IconButton(onClick = { onExpandedChange(!isExpanded) }) { - Icon( - imageVector = if (isExpanded) { - Icons.Rounded.KeyboardArrowUp - } else { - Icons.Rounded.KeyboardArrowDown - }, - contentDescription = if (isExpanded) { - stringResource(R.string.action_list_item_collapse) - } else { - stringResource(R.string.action_list_item_expand) - }, - tint = MaterialTheme.colorScheme.onSurface, - ) + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + IconButton(onClick = { onExpandedChange(!isExpanded) }) { + Icon( + imageVector = if (isExpanded) { + Icons.Rounded.KeyboardArrowUp + } else { + Icons.Rounded.KeyboardArrowDown + }, + contentDescription = if (isExpanded) { + stringResource(R.string.action_list_item_collapse) + } else { + stringResource(R.string.action_list_item_expand) + }, + tint = MaterialTheme.colorScheme.onSurface, + ) + } } - } - Spacer(Modifier.width(4.dp)) - } + Spacer(Modifier.width(4.dp)) + } - AnimatedVisibility( - visible = isExpanded, - enter = expandTransition, - exit = collapseTransition, - ) { - 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, - onEnabledChange = onEnabledChange, - ) + AnimatedVisibility( + visible = isExpanded, + enter = expandTransition, + exit = collapseTransition, + ) { + 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, + onEnabledChange = onEnabledChange, + ) + } } } } @@ -299,7 +355,11 @@ private fun HeaderText( overflow = TextOverflow.Ellipsis, ) - if (isExpanded) { + AnimatedVisibility( + visible = isExpanded, + enter = renameButtonEnterTransition, + exit = renameButtonExitTransition, + ) { CompositionLocalProvider( LocalMinimumInteractiveComponentSize provides 16.dp, ) { @@ -316,8 +376,8 @@ private fun HeaderText( AnimatedVisibility( visible = !isExpanded, - enter = expandTransition, - exit = collapseTransition, + enter = summaryEnterTransition, + exit = summaryExitTransition, ) { Column { if (model.summary != null) { 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 113610c1b0..f920f34fc5 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 @@ -334,7 +334,9 @@ private fun ActionList( ) { isDragging -> Column { ActionListItem( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), model = model, index = index, isExpanded = model.id in expandedIds, From c1daa10025ddedfde95cd1725e7983d5abaa0509 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 17 Sep 2026 10:45:38 +0200 Subject: [PATCH 029/101] create reusable CompactButton composable --- .../keymapper/base/actions/ActionListItem.kt | 16 +-- .../base/utils/ui/compose/CompactButton.kt | 113 ++++++++++++++++++ 2 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactButton.kt 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 7ef9564460..29eb71fdb1 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 @@ -16,11 +16,9 @@ import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -36,8 +34,6 @@ import androidx.compose.material.icons.rounded.KeyboardArrowUp 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.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon @@ -66,6 +62,7 @@ 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.ComposeIconInfo import io.github.sds100.keymapper.base.utils.ui.compose.DragDropState import io.github.sds100.keymapper.base.utils.ui.drawable @@ -365,16 +362,7 @@ private fun ExpandedContent( if (model.isErrorFixable) { Spacer(Modifier.width(8.dp)) - Button( - modifier = Modifier.height(28.dp), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 2.dp), - shape = MaterialTheme.shapes.small, - onClick = onFixClick, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { + CompactErrorButton(onClick = onFixClick) { Text(stringResource(R.string.button_fix)) } } 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..147ca2bbae --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactButton.kt @@ -0,0 +1,113 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +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(), + content: @Composable RowScope.() -> Unit, +) { + OutlinedButton( + modifier = modifier.height(compactButtonHeight), + onClick = onClick, + enabled = enabled, + shape = MaterialTheme.shapes.small, + colors = colors, + contentPadding = compactButtonContentPadding, + 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() + } +} From d2b5f059a0d553cd0e8eb352459f73ac50ef6540 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 17 Sep 2026 20:40:43 +0200 Subject: [PATCH 030/101] #752 feat: overhaul constraints with groups --- CHANGELOG.md | 4 + .../keymapper/base/actions/ActionsScreen.kt | 2 +- .../base/actions/ConfigActionsUseCase.kt | 4 +- .../keymapper/base/backup/BackupManager.kt | 7 + .../constraints/ChooseConstraintViewModel.kt | 112 +--- .../constraints/ConfigConstraintsUseCase.kt | 150 ++++- .../constraints/ConfigConstraintsViewModel.kt | 94 ++- .../keymapper/base/constraints/Constraint.kt | 629 +----------------- .../constraints/ConstraintEntityMapper.kt | 469 +++++++++++++ .../constraints/ConstraintErrorSnapshot.kt | 30 +- .../base/constraints/ConstraintGroup.kt | 15 + .../base/constraints/ConstraintGroupItem.kt | 540 +++++++++++++++ .../base/constraints/ConstraintId.kt | 14 - .../base/constraints/ConstraintListItem.kt | 325 +++++---- .../constraints/ConstraintListItemModel.kt | 15 +- .../base/constraints/ConstraintModeButtons.kt | 69 ++ .../constraints/ConstraintModeEntityMapper.kt | 16 + .../base/constraints/ConstraintSnapshot.kt | 94 +-- .../base/constraints/ConstraintState.kt | 10 +- .../ConstraintStateEntityMapper.kt | 126 ++++ .../base/constraints/ConstraintUiHelper.kt | 223 +++++-- .../base/constraints/ConstraintUtils.kt | 186 ++---- .../base/constraints/ConstraintsScreen.kt | 382 ++++++++--- .../constraints/CreateConstraintUseCase.kt | 2 +- .../sds100/keymapper/base/groups/Group.kt | 22 +- .../keymapper/base/home/ListKeyMapsUseCase.kt | 14 +- .../sds100/keymapper/base/keymaps/KeyMap.kt | 25 +- .../KeyMapConstraintsComparator.kt | 23 - .../base/trigger/BaseTriggerScreen.kt | 2 + .../base/utils/ui/compose/CompactButton.kt | 3 + .../base/utils/ui/compose/ComposeDialogs.kt | 1 + base/src/main/res/values/strings.xml | 48 +- .../base/actions/ConfigActionsUseCaseTest.kt | 9 +- .../ConstraintEntityDeserializerTest.kt | 64 ++ .../constraints/ConstraintEntityMapperTest.kt | 322 +++++++++ .../constraints/ConstraintSnapshotTest.kt | 420 ++++++++---- .../ConstraintStateEntityMapperTest.kt | 365 ++++++++++ .../base/keymaps/DetectKeyMapsUseCaseTest.kt | 57 +- .../base/keymaps/KeyMapAlgorithmTest.kt | 134 +++- .../base/utils/TestConstraintSnapshot.kt | 31 +- .../23.json | 482 ++++++++++++++ .../sds100/keymapper/data/db/AppDatabase.kt | 5 +- .../sds100/keymapper/data/db/dao/KeyMapDao.kt | 1 + .../ConstraintListTypeConverter.kt | 13 +- .../data/entities/ConstraintEntity.kt | 45 ++ .../data/entities/ConstraintGroupEntity.kt | 36 + .../keymapper/data/entities/KeyMapEntity.kt | 12 + .../data/migration/AutoMigration22To23.kt | 5 + 48 files changed, 4078 insertions(+), 1579 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroup.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintModeButtons.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintModeEntityMapper.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapper.kt create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityDeserializerTest.kt create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapperTest.kt create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapperTest.kt create mode 100644 data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/23.json create mode 100644 data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintGroupEntity.kt create mode 100644 data/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration22To23.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d53d91a01..3e5eaa89e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - #1465 actions can be turned off individually without deleting them. - #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. ## [4.4.1](https://github.com/sds100/KeyMapper/releases/tag/v4.4.1) 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 f920f34fc5..c20758bca4 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 @@ -296,7 +296,7 @@ private fun ActionList( onButtonClick = { callback.onTipButtonClick(tip.id) }, ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) } } 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 e383a5267c..d63b6c8024 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 @@ -239,11 +239,11 @@ class ConfigActionsUseCaseImpl @Inject constructor( } 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( 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..50a8a28bee 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 @@ -182,25 +182,15 @@ 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.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 +254,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 +287,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 +296,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 +504,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 +533,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 +545,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,23 +566,12 @@ 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", - ) - } - - returnResult.emit(constraintData) + ), + ) } private suspend fun onSelectAppConstraint(type: ConstraintId) { @@ -657,18 +587,10 @@ class ChooseConstraintViewModel @Inject constructor( packageName = packageName, ) - ConstraintId.APP_NOT_IN_FOREGROUND -> ConstraintData.AppNotInForeground( - packageName = packageName, - ) - ConstraintId.APP_PLAYING_MEDIA -> ConstraintData.AppPlayingMedia( packageName = packageName, ) - ConstraintId.APP_NOT_PLAYING_MEDIA -> ConstraintData.AppNotPlayingMedia( - packageName = packageName, - ) - else -> throw IllegalArgumentException( "Don't know how to create $type constraint after choosing app", ) 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..ccab283be3 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 @@ -14,6 +14,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( @@ -40,17 +41,40 @@ class ConfigConstraintsUseCaseImpl @Inject constructor( .take(5) } - override fun addConstraint(constraintData: ConstraintData): Boolean { + override fun addConstraint(groupUid: String?, constraintData: ConstraintData): Boolean { var containsConstraint = false val newConstraint = Constraint(data = constraintData) updateConstraintState { oldState -> - containsConstraint = oldState.constraints.any { it.data == constraintData } + val group = oldState.groups.find { it.uid == groupUid } + + if (group == null) { + val newGroup = ConstraintGroup(constraints = listOf(newConstraint)) + + // The mode between groups is not used when there is only one group and old + // constraints load it from the group mode, so groups are combined with AND by + // default when a second group is created. + val mode = if (oldState.groups.size <= 1) { + ConstraintMode.AND + } else { + oldState.mode + } + + 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) { + it.copy(constraints = it.constraints.plus(newConstraint)) + } } } @@ -70,24 +94,71 @@ class ConfigConstraintsUseCaseImpl @Inject constructor( return !containsConstraint } - override fun removeConstraint(id: String) { + 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() } + + oldState.copy(groups = groups) + } + } + + override fun removeGroup(groupUid: String) { + updateConstraintState { oldState -> + oldState.copy(groups = oldState.groups.filterNot { it.uid == groupUid }) + } + } + + 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 setMode(mode: ConstraintMode) { + updateConstraintState { oldState -> + oldState.copy(mode = mode) + } + } + + override fun setGroupMode(groupUid: String, mode: ConstraintMode) { + updateConstraintState { oldState -> + oldState.updateGroup(groupUid) { it.copy(mode = mode) } + } + } + + override fun setGroupName(groupUid: String, name: String?) { + updateConstraintState { oldState -> + oldState.updateGroup(groupUid) { it.copy(name = name?.trim()?.ifBlank { null }) } } } - override fun setAndMode() { + override fun moveGroup(fromIndex: Int, toIndex: Int) { updateConstraintState { oldState -> - oldState.copy(mode = ConstraintMode.AND) + oldState.copy(groups = oldState.groups.move(fromIndex, toIndex)) } } - override fun setOrMode() { + override fun moveConstraint(groupUid: String, fromIndex: Int, toIndex: Int) { updateConstraintState { oldState -> - oldState.copy(mode = ConstraintMode.OR) + oldState.updateGroup(groupUid) { group -> + group.copy(constraints = group.constraints.move(fromIndex, toIndex)) + } } } @@ -97,13 +168,46 @@ class ConfigConstraintsUseCaseImpl @Inject constructor( } } + 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 { if (json == null) { return emptyList() } 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 +218,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 false if the group already contains the constraint. + */ + fun addConstraint(groupUid: String?, constraintData: ConstraintData): Boolean + 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) + fun moveConstraint(groupUid: String, 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..a0b0cc4e6e 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 @@ -84,17 +84,30 @@ class ConfigConstraintsViewModel @Inject constructor( fun onClickShortcut(constraintData: ConstraintData) { viewModelScope.launch { - config.addConstraint(constraintData) + config.addConstraint(groupUid = null, constraintData) } } 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 onMoveConstraint(groupUid: String, fromIndex: Int, toIndex: Int) { + config.moveConstraint(groupUid, fromIndex, toIndex) } fun onFixError(constraintUid: String) { @@ -133,13 +146,16 @@ 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 = navigate("add_constraint", NavDestination.ChooseConstraint) ?: return@launch - val isDuplicate = !config.addConstraint(constraint) + val isDuplicate = !config.addConstraint(groupUid, constraint) if (isDuplicate) { showDuplicateConstraintsSnackbar = true @@ -167,30 +183,41 @@ class ConfigConstraintsViewModel @Inject constructor( 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 = uiHelper.getIcon(constraint), + text = uiHelper.getTitle(constraint), + isNot = constraint.isNot, + error = error?.getFullMessage(this), + isErrorFixable = error?.isFixable ?: true, + ) + } - 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, - ) - } + 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) + }, + ) + } return ConfigConstraintsState.Loaded( - constraintList = constraintList, - selectedMode = state.mode, + groups = groups, + mode = state.mode, shortcuts = shortcuts, ) } @@ -201,8 +228,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/Constraint.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt index f5d3c0ec57..602296b08a 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 @@ -22,53 +16,27 @@ sealed class 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() { 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 +64,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 +86,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 +145,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 @@ -229,11 +160,6 @@ sealed class ConstraintData { override val id: ConstraintId = ConstraintId.NOTIFICATION_PANEL_SHOWING } - @Serializable - data object NotificationPanelNotShowing : ConstraintData() { - override val id: ConstraintId = ConstraintId.NOTIFICATION_PANEL_NOT_SHOWING - } - @Serializable data class Time( val startHour: Int, @@ -249,546 +175,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/ConstraintEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt new file mode 100644 index 0000000000..cde4aee3f2 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt @@ -0,0 +1,469 @@ +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 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()) + + ConstraintEntity.APP_PLAYING_MEDIA, + ConstraintEntity.APP_NOT_PLAYING_MEDIA, + -> ConstraintData.AppPlayingMedia(getPackageName()) + + 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.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 = 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.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.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/ConstraintErrorSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshot.kt index 67eecff0af..9ede18510e 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 @@ -38,7 +38,6 @@ 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.AppPlayingMedia -> { if (!isPermissionGranted(Permission.NOTIFICATION_LISTENER)) { @@ -48,23 +47,13 @@ class LazyConstraintErrorSnapshot( return getAppError(constraint.data.packageName) } - is ConstraintData.AppNotPlayingMedia -> { - if (!isPermissionGranted(Permission.NOTIFICATION_LISTENER)) { - return SystemError.PermissionDenied(Permission.NOTIFICATION_LISTENER) - } - - return getAppError(constraint.data.packageName) - } - - ConstraintData.MediaPlaying, ConstraintData.NoMediaPlaying -> { + ConstraintData.MediaPlaying -> { 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) } @@ -95,20 +84,7 @@ class LazyConstraintErrorSnapshot( } } - 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 - CameraLens.BACK -> KMError.BackFlashNotFound - } - } - } - - is ConstraintData.WifiConnected, is ConstraintData.WifiDisconnected -> { + is ConstraintData.WifiConnected -> { if (!isPermissionGranted(Permission.ACCESS_FINE_LOCATION)) { return SystemError.PermissionDenied(Permission.ACCESS_FINE_LOCATION) } 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..4cf56591de --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt @@ -0,0 +1,540 @@ +package io.github.sds100.keymapper.base.constraints + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +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.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.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateMapOf +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.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.pluralStringResource +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.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 androidx.compose.ui.zIndex +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 + +@Composable +fun ConstraintGroupItem( + modifier: Modifier = Modifier, + model: ConstraintGroupListItemModel, + isExpanded: Boolean, + 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 = {}, + onMoveConstraint: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }, + onMoveUp: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, +) { + 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 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 + + ElevatedCard( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .semantics { + if (isReorderingEnabled) { + customActions = buildList { + onMoveUp?.let { action -> + add( + CustomAccessibilityAction(moveUpLabel) { + action() + true + }, + ) + } + onMoveDown?.let { action -> + add( + CustomAccessibilityAction(moveDownLabel) { + action() + true + }, + ) + } + } + } + }, + colors = CardDefaults.elevatedCardColors( + containerColor = if (isDragging) { + MaterialTheme.colorScheme.surfaceContainerHighest + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + ), + ) { + Column(modifier = Modifier.animateContentSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .clickable { onExpandedChange(!isExpanded) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(8.dp)) + + if (isReorderingEnabled) { + Icon( + modifier = Modifier + .size(24.dp) + .draggable( + state = draggableState, + orientation = Orientation.Vertical, + startDragImmediately = true, + onDragStarted = { + dragDropState?.onDragStart(model.uid) + }, + onDragStopped = { dragDropState?.onDragInterrupted() }, + ), + imageVector = Icons.Rounded.DragHandle, + contentDescription = stringResource(R.string.drag_handle_for, title), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(Modifier.width(8.dp)) + + HeaderText( + modifier = Modifier.weight(1f), + title = title, + model = model, + isExpanded = isExpanded, + ) + + IconButton(onClick = onRenameClick) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = stringResource(R.string.constraint_group_rename), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + IconButton(onClick = { onExpandedChange(!isExpanded) }) { + Icon( + imageVector = if (isExpanded) { + Icons.Rounded.KeyboardArrowUp + } else { + Icons.Rounded.KeyboardArrowDown + }, + contentDescription = if (isExpanded) { + stringResource(R.string.constraint_group_collapse) + } else { + stringResource(R.string.constraint_group_expand) + }, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(Modifier.width(4.dp)) + } + + AnimatedVisibility(visible = isExpanded) { + 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, + onMoveConstraint = onMoveConstraint, + ) + } + } + } +} + +@Composable +private fun HeaderText( + modifier: Modifier = Modifier, + title: String, + model: ConstraintGroupListItemModel, + isExpanded: Boolean, +) { + Column(modifier = modifier) { + if (isExpanded) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + text = model.name ?: model.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + 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, + onMoveConstraint: (fromIndex: Int, toIndex: Int) -> 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, + onMoveConstraint = onMoveConstraint, + ) + + 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)) + + 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, + ) + } + } + } +} + +/** + * The constraints in a group. These are not in a lazy list so they are reordered by swapping + * the dragged constraint with its neighbour once it has been dragged over half of its height. + */ +@Composable +private fun GroupConstraintList( + modifier: Modifier = Modifier, + constraints: List, + mode: ConstraintMode, + onRemoveConstraintClick: (String) -> Unit, + onFixConstraintClick: (String) -> Unit, + onNotClick: (String) -> Unit, + onMoveConstraint: (fromIndex: Int, toIndex: Int) -> Unit, +) { + var draggingIndex by remember { mutableStateOf(null) } + var dragOffset by remember { mutableFloatStateOf(0f) } + val itemHeights = remember { mutableStateMapOf() } + + 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) { + val isDragging = draggingIndex == index + + val draggableState = rememberDraggableState { delta -> + val currentIndex = draggingIndex ?: return@rememberDraggableState + val itemHeight = constraints.getOrNull(currentIndex) + ?.let { itemHeights[it.id] } + ?: return@rememberDraggableState + + dragOffset += delta + + if (dragOffset > itemHeight / 2f && currentIndex < constraints.lastIndex) { + onMoveConstraint(currentIndex, currentIndex + 1) + draggingIndex = currentIndex + 1 + dragOffset -= itemHeight + } else if (dragOffset < -itemHeight / 2f && currentIndex > 0) { + onMoveConstraint(currentIndex, currentIndex - 1) + draggingIndex = currentIndex - 1 + dragOffset += itemHeight + } + } + + Column( + modifier = Modifier + .onSizeChanged { itemHeights[constraint.id] = it.height } + .zIndex(if (isDragging) 1f else 0f) + .graphicsLayer { translationY = if (isDragging) dragOffset else 0f }, + ) { + ConstraintListItem( + modifier = Modifier.fillMaxWidth(), + model = constraint, + isReorderingEnabled = constraints.size > 1, + isDragging = isDragging, + dragHandleModifier = Modifier.draggable( + state = draggableState, + orientation = Orientation.Vertical, + startDragImmediately = true, + onDragStarted = { + draggingIndex = index + dragOffset = 0f + }, + onDragStopped = { + draggingIndex = null + dragOffset = 0f + }, + ), + onRemoveClick = { onRemoveConstraintClick(constraint.id) }, + onFixClick = { onFixConstraintClick(constraint.id) }, + onNotClick = { onNotClick(constraint.id) }, + onMoveUp = if (index > 0) { + { onMoveConstraint(index, index - 1) } + } else { + null + }, + onMoveDown = if (index < constraints.lastIndex) { + { onMoveConstraint(index, index + 1) } + } else { + null + }, + ) + + 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..2d9da6f038 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,11 @@ enum class ConstraintId { RINGER_MODE_SILENT, CHARGING, - DISCHARGING, HINGE_CLOSED, HINGE_OPEN, NOTIFICATION_PANEL_SHOWING, - NOTIFICATION_PANEL_NOT_SHOWING, 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..4b52b33478 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,23 +1,23 @@ package io.github.sds100.keymapper.base.constraints +import androidx.compose.foundation.BorderStroke 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.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.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 @@ -30,180 +30,251 @@ 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.CustomAccessibilityAction +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +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 +// TODO create a compact version with FIX, NOT below the text, and no icon, no error text. Just show fix button. @Composable fun ConstraintListItem( modifier: Modifier = Modifier, model: ConstraintListItemModel, + isReorderingEnabled: Boolean = false, + isDragging: Boolean = false, + /** + * The modifier that makes the drag handle draggable. + */ + dragHandleModifier: Modifier = Modifier, onRemoveClick: () -> Unit = {}, onFixClick: () -> Unit = {}, + onNotClick: () -> Unit = {}, + onMoveUp: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, ) { - 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)) + val moveUpLabel = stringResource(R.string.accessibility_action_move_up) + val moveDownLabel = stringResource(R.string.accessibility_action_move_down) - 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, + 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 = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + elevation = if (isDragging) { + CardDefaults.elevatedCardElevation(defaultElevation = 6.dp) + } else { + CardDefaults.elevatedCardElevation() + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(8.dp)) - val primaryText = model.text + if (isReorderingEnabled) { + Icon( + modifier = Modifier + .size(24.dp) + .then(dragHandleModifier), + imageVector = Icons.Rounded.DragHandle, + contentDescription = stringResource(R.string.drag_handle_for, model.text), + ) Spacer(Modifier.width(8.dp)) + } - TextColumn( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp), - primaryText = primaryText, - errorText = model.error, + // TODO only show if sufficient horizontal space + ConstraintIcon(icon = model.icon) + + Spacer(Modifier.width(8.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = model.text, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) - 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), - ) - } - } + if (model.error != null) { + Text( + text = model.error, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + ) + } + } + + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + if (model.error != null && model.isErrorFixable) { + Spacer(Modifier.width(8.dp)) - 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), - ) + CompactErrorButton(onClick = onFixClick) { + Text(stringResource(R.string.button_fix)) } } - } - } - 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)) + Spacer(Modifier.width(8.dp)) + + NotToggle(isNot = model.isNot, onClick = onNotClick) - val text = when (model.constraintModeLink) { - ConstraintMode.AND -> stringResource(R.string.constraint_mode_and) - ConstraintMode.OR -> stringResource(R.string.constraint_mode_or) + IconButton(onClick = onRemoveClick) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = Icons.Rounded.Clear, + contentDescription = stringResource(R.string.constraint_list_item_remove), + ) + } } + } + } +} - Text( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = text, - ) +@Composable +private fun NotToggle(modifier: Modifier = Modifier, isNot: Boolean, onClick: () -> Unit) { + val contentDescription = stringResource(R.string.constraint_list_item_not) - Spacer(Modifier.height(4.dp)) + 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 TextColumn( - modifier: Modifier = Modifier, - primaryText: String, - errorText: String? = null, -) { - Column( - modifier = modifier, - ) { - Text( - text = primaryText, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, +private fun ConstraintIcon(modifier: Modifier = Modifier, icon: ComposeIconInfo) { + when (icon) { + is ComposeIconInfo.Vector -> Icon( + modifier = modifier.size(24.dp), + imageVector = icon.imageVector, + contentDescription = null, ) - if (errorText != null) { - Text( - text = errorText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, + + is ComposeIconInfo.Drawable -> { + val painter = rememberDrawablePainter(icon.drawable) + Icon( + modifier = modifier.size(24.dp), + painter = painter, + contentDescription = null, + tint = Color.Unspecified, ) } } } -@Preview +@PreviewLightDark @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", + ), + isReorderingEnabled = true, + ) + } +} + +@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 = "Flashlight not found", + isErrorFixable = true, + ), + isReorderingEnabled = true, + ) + } } -@Preview +@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), + ), + isReorderingEnabled = true, + ) + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItemModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItemModel.kt index 39f2ca17a2..0159dcf6f5 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,18 @@ 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 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..bf1a6e73a9 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintModeButtons.kt @@ -0,0 +1,69 @@ +package io.github.sds100.keymapper.base.constraints + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.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, +) { + 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, + ) +} + +@Preview +@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..09efcc3eb3 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 @@ -95,33 +95,16 @@ 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 || @@ -134,7 +117,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,7 +131,6 @@ 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) { @@ -160,22 +141,10 @@ 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 +160,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 +188,8 @@ 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.Time -> if (constraint.data.startTime.isAfter(constraint.data.endTime)) { @@ -237,11 +201,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 +218,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..f1642c3e04 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 constraints: 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..ecf2885553 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 @@ -22,38 +22,28 @@ class ConstraintUiHelper( private val timeFormatter by lazy { TimeUtils.localeDateFormatter(FormatStyle.SHORT) } - fun getTitle(constraint: Constraint): String = when (constraint.data) { + fun getTitle(constraint: Constraint): String { + return if (constraint.isNot) { + getNotTitle(constraint) + } else { + getNormalTitle(constraint) + } + } + + private fun getNormalTitle(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) }, ) - 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) }, - ) - 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) }, ) - 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.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 +51,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 +72,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 +86,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 +94,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 +108,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 +118,35 @@ 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.Time -> getString( R.string.constraint_time_formatted, arrayOf( @@ -195,20 +156,146 @@ class ConstraintUiHelper( ) } + private fun getNotTitle(constraint: Constraint): String = when (constraint.data) { + is ConstraintData.AppInForeground -> + getAppName(constraint.data.packageName).handle( + onSuccess = { getString(R.string.constraint_app_not_foreground_description, it) }, + onError = { getString(R.string.constraint_choose_app_not_foreground) }, + ) + + is ConstraintData.AppPlayingMedia -> + getAppName(constraint.data.packageName).handle( + onSuccess = { + getString(R.string.constraint_app_not_playing_media_description, it) + }, + onError = { getString(R.string.constraint_choose_app_not_playing_media) }, + ) + + 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_not_in_phone_call) + + is ConstraintData.NotInPhoneCall -> getString(R.string.constraint_in_phone_call) + + 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.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) - - else -> ConstraintUtils.getIcon(constraint.id) + else -> if (constraint.isNot) { + ConstraintUtils.getNotIcon(constraint.id) + } else { + ConstraintUtils.getIcon(constraint.id) + } } 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..55a85cc328 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 @@ -53,21 +53,14 @@ object ConstraintUtils { 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 +74,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 +96,28 @@ 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_PANEL_SHOWING -> ConstraintCategory.DISPLAY 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 +142,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 +172,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 +180,59 @@ 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.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.FLASHLIGHT_ON -> ComposeIconInfo.Vector(Icons.Outlined.FlashlightOff) + + 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.APP_NOT_PLAYING_MEDIA -> R.string.constraint_choose_app_not_playing_media + 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) + + else -> getIcon(constraintId) + } + + fun getTitleStringId(constraintId: ConstraintId): Int = when (constraintId) { + ConstraintId.APP_IN_FOREGROUND -> R.string.constraint_choose_app_foreground - ConstraintId.MEDIA_NOT_PLAYING -> R.string.constraint_choose_media_not_playing + 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 +262,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 +286,53 @@ 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.TIME -> R.string.constraint_time } 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 +343,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,10 +351,8 @@ 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) } 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..de6e71b47e 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, + onMoveGroup = viewModel::onMoveGroup, + onMoveConstraint = viewModel::onMoveConstraint, ) } @@ -80,39 +97,93 @@ 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 = { _, _ -> }, + onMoveGroup: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }, + onMoveConstraint: (groupUid: String, 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 = { showDeleteDialog = false }) { + TextButton(onClick = { constraintToDelete = null }) { Text(stringResource(R.string.constraint_list_delete_cancel)) } }, ) } - when (val state = state) { + if (groupToDelete != null) { + AlertDialog( + onDismissRequest = { groupToDelete = null }, + title = { + Text(stringResource(R.string.constraint_group_delete_dialog_title)) + }, + text = { Text(stringResource(R.string.constraint_group_delete_dialog_text)) }, + confirmButton = { + TextButton( + onClick = { + onRemoveGroupClick(groupToDelete!!) + groupToDelete = null + }, + ) { + Text(stringResource(R.string.constraint_list_delete_yes)) + } + }, + dismissButton = { + TextButton(onClick = { groupToDelete = null }) { + Text(stringResource(R.string.constraint_list_delete_cancel)) + } + }, + ) + } + + val groups = ((state as? State.Data)?.data as? ConfigConstraintsState.Loaded) + ?.groups + .orEmpty() + 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(), + 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 +223,49 @@ 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)) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp), + text = stringResource(R.string.constraint_list_explanation), + style = MaterialTheme.typography.titleSmall, + textAlign = TextAlign.Center, + ) - 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 }, + onMoveGroup = onMoveGroup, + onMoveConstraint = onMoveConstraint, ) - 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 +287,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 +295,149 @@ 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, + onMoveGroup: (fromIndex: Int, toIndex: Int) -> Unit, + onMoveConstraint: (groupUid: String, fromIndex: Int, toIndex: Int) -> Unit, ) { val lazyListState = rememberLazyListState() + val groupUids = state.groups.map { it.uid } + + // Lists rather than sets so they can be saved in a Bundle. + var expandedUids by rememberSaveable { mutableStateOf(groupUids) } + var knownGroupUids by rememberSaveable { mutableStateOf(groupUids) } + + // Expand groups that are added so the user can see the new constraint. + LaunchedEffect(groupUids) { + val newGroupUids = groupUids.filterNot { it in knownGroupUids } + + if (newGroupUids.isNotEmpty()) { + expandedUids = expandedUids + newGroupUids + } + + knownGroupUids = groupUids + } + + // Only the groups can be dragged. Not the row of shortcuts. + val dragDropState = rememberDragDropState( + lazyListState = lazyListState, + keys = groupUids, + onMove = onMoveGroup, + // Collapse all the groups so they are a similar height while dragging. + onStart = { expandedUids = emptyList() }, + ) + + 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) }, - ) + itemsIndexed( + orderedGroups, + key = { _, group -> group.uid }, + contentType = { _, _ -> "constraint_group" }, + ) { index, group -> + // Automatically expand the group when an error appears so the user can fix it. + LaunchedEffect(group.uid, group.error) { + if (group.error != null && group.uid !in expandedUids) { + expandedUids = expandedUids + group.uid + } + } + + DraggableItem( + dragDropState = dragDropState, + key = group.uid, + ) { isDragging -> + Column { + ConstraintGroupItem( + modifier = Modifier.fillMaxWidth(), + model = group, + isExpanded = group.uid in expandedUids, + isDragging = isDragging, + isReorderingEnabled = state.groups.size > 1, + dragDropState = dragDropState, + onExpandedChange = { expanded -> + expandedUids = if (expanded) { + expandedUids + group.uid + } else { + expandedUids - group.uid + } + }, + onSelectMode = { onSelectGroupMode(group.uid, it) }, + onAddConstraintClick = { onAddToGroupClick(group.uid) }, + onRenameClick = { onRenameGroupClick(group.uid) }, + onDeleteClick = { onRemoveGroupClick(group.uid) }, + onRemoveConstraintClick = onRemoveClick, + onFixConstraintClick = onFixErrorClick, + onNotClick = onNotClick, + onMoveConstraint = { fromIndex, toIndex -> + onMoveConstraint(group.uid, fromIndex, toIndex) + }, + 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 (shortcuts.isNotEmpty()) { + 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 +466,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 +525,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/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/home/ListKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt index 351dc92631..d08b47e45b 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 @@ -289,12 +289,18 @@ 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)) 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 9ea161792b..539079d242 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,9 +5,8 @@ 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 @@ -125,17 +124,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, ) @@ -143,17 +143,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/sorting/comparators/KeyMapConstraintsComparator.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt index 21cfdde574..047e460fe3 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 @@ -85,35 +85,22 @@ 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.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}", @@ -124,15 +111,7 @@ class KeyMapConstraintsComparator( Success(constraint.data.ssid) } - is ConstraintData.WifiDisconnected -> 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 @@ -142,13 +121,11 @@ 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/trigger/BaseTriggerScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt index ed8c073118..c7f7d63801 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 @@ -116,6 +116,8 @@ fun BaseTriggerScreen( is State.Data -> { val tipContent: @Composable () -> Unit = { tipModel?.let { tip -> + Spacer(Modifier.height(8.dp)) + TipCard( modifier = Modifier .fillMaxWidth() 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 index 147ca2bbae..234b4eccb1 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -29,6 +30,7 @@ fun CompactOutlinedButton( modifier: Modifier = Modifier, enabled: Boolean = true, colors: ButtonColors = ButtonDefaults.outlinedButtonColors(), + border: BorderStroke = ButtonDefaults.outlinedButtonBorder(), content: @Composable RowScope.() -> Unit, ) { OutlinedButton( @@ -38,6 +40,7 @@ fun CompactOutlinedButton( shape = MaterialTheme.shapes.small, colors = colors, contentPadding = compactButtonContentPadding, + border = border, content = content, ) } 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 10a9cf2f08..b0816ed60e 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 @@ -193,6 +193,7 @@ fun CustomDialogContent( ) } + Spacer(modifier = Modifier.height(8.dp)) // HorizontalDivider() Box(Modifier.weight(1f, fill = false), content = content) // HorizontalDivider() diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 81b4dd3c53..a730118df0 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -240,6 +240,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 @@ -264,14 +265,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 @@ -311,9 +323,13 @@ In phone call Not in phone call Phone ringing + Phone 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 @@ -321,6 +337,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 @@ -332,6 +350,7 @@ Time Time between %s and %s + Time not between %s and %s Time constraint Start time Edit start time @@ -843,7 +862,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! @@ -1879,9 +1898,32 @@ Are you sure you want to delete this constraint? Yes, delete Cancel - Logic mode + This key map will only run\nif 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 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 b33fa2cdf9..ff013bacdb 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 @@ -23,6 +23,7 @@ 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 @@ -168,7 +169,9 @@ class ConfigActionsUseCaseTest { useCase.addAction(action) // THEN - verify(mockConfigConstraintsUseCase).addConstraint(any()) + verify( + mockConfigConstraintsUseCase, + ).addConstraint(anyOrNull(), any()) } /** @@ -186,7 +189,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/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..3e2f0c742b --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapperTest.kt @@ -0,0 +1,322 @@ +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)) + } + } + + 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/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..6fb68da1c1 --- /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.constraints, + `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/keymaps/DetectKeyMapsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DetectKeyMapsUseCaseTest.kt index 45cc078e17..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 @@ -4,6 +4,7 @@ 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 @@ -25,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", @@ -46,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( @@ -76,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)) @@ -93,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), @@ -115,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)) @@ -128,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( @@ -148,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)) @@ -315,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..73b32697bb 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 @@ -768,27 +769,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 +837,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, ), ), ), @@ -1713,14 +1751,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 +1810,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 +1971,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/utils/TestConstraintSnapshot.kt b/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt index 1af4cf04f2..3cbbfd9de2 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 @@ -43,23 +43,14 @@ class TestConstraintSnapshot( 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.BtDeviceDisconnected -> { - connectedBluetoothDevices.none { it.address == data.bluetoothAddress } - } - is ConstraintData.OrientationCustom -> orientation == data.orientation is ConstraintData.OrientationLandscape -> orientation == Orientation.ORIENTATION_90 || @@ -72,15 +63,10 @@ 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 +82,16 @@ 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 @@ -136,8 +108,7 @@ class TestConstraintSnapshot( 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/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/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/ConstraintEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt index 9e8a884ebe..d2eb275787 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,14 +128,21 @@ 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 TIME = "time" const val EXTRA_PACKAGE_NAME = "extra_package_name" @@ -131,10 +171,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/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 From 19a5083493a96e0fcb43d57784eedf9ab3c3d41d Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 17 Sep 2026 21:17:00 +0200 Subject: [PATCH 031/101] #752 adapt constraints screen to smaller screens --- .../base/constraints/ConstraintListItem.kt | 272 ++++++++++++++---- .../base/constraints/ConstraintsScreen.kt | 20 +- base/src/main/res/values/strings.xml | 2 +- 3 files changed, 236 insertions(+), 58 deletions(-) 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 4b52b33478..c002da0816 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItem.kt @@ -1,6 +1,8 @@ package io.github.sds100.keymapper.base.constraints import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -49,7 +51,6 @@ 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 -// TODO create a compact version with FIX, NOT below the text, and no icon, no error text. Just show fix button. @Composable fun ConstraintListItem( modifier: Modifier = Modifier, @@ -103,56 +104,96 @@ fun ConstraintListItem( CardDefaults.elevatedCardElevation() }, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(Modifier.width(8.dp)) + val listItemModifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(vertical = 4.dp) - if (isReorderingEnabled) { - Icon( - modifier = Modifier - .size(24.dp) - .then(dragHandleModifier), - imageVector = Icons.Rounded.DragHandle, - contentDescription = stringResource(R.string.drag_handle_for, model.text), - ) + BoxWithConstraints { + when { + maxWidth < 300.dp -> { + ConstraintListItemSmall( + modifier = listItemModifier, + isReorderingEnabled = isReorderingEnabled, + dragHandleModifier = dragHandleModifier, + model = model, + onFixClick = onFixClick, + onNotClick = onNotClick, + onRemoveClick = onRemoveClick, + ) + } - Spacer(Modifier.width(8.dp)) + maxWidth < 400.dp -> { + ConstraintListItemMedium( + modifier = listItemModifier, + isReorderingEnabled = isReorderingEnabled, + dragHandleModifier = dragHandleModifier, + model = model, + onFixClick = onFixClick, + onNotClick = onNotClick, + onRemoveClick = onRemoveClick, + ) + } + + else -> { + ConstraintListItemLarge( + modifier = listItemModifier, + isReorderingEnabled = isReorderingEnabled, + dragHandleModifier = dragHandleModifier, + model = model, + onFixClick = onFixClick, + onNotClick = onNotClick, + onRemoveClick = onRemoveClick, + ) + } } + } + } +} - // TODO only show if sufficient horizontal space - ConstraintIcon(icon = model.icon) +@Composable +private fun ConstraintListItemSmall( + modifier: Modifier = Modifier, + isReorderingEnabled: Boolean, + dragHandleModifier: Modifier, + model: ConstraintListItemModel, + onFixClick: () -> Unit, + onNotClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(8.dp)) - Spacer(Modifier.width(8.dp)) + if (isReorderingEnabled) { + Icon( + modifier = Modifier + .size(24.dp) + .then(dragHandleModifier), + imageVector = Icons.Rounded.DragHandle, + contentDescription = stringResource(R.string.drag_handle_for, model.text), + ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = model.text, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + Spacer(Modifier.width(8.dp)) + } - if (model.error != null) { - Text( - text = model.error, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, - ) - } - } + Column(modifier = Modifier.weight(1f)) { + Text( + modifier = Modifier.fillMaxWidth(), + text = model.text, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) - CompositionLocalProvider( - LocalMinimumInteractiveComponentSize provides 16.dp, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, ) { if (model.error != null && model.isErrorFixable) { - Spacer(Modifier.width(8.dp)) - CompactErrorButton(onClick = onFixClick) { Text(stringResource(R.string.button_fix)) } @@ -161,16 +202,150 @@ fun ConstraintListItem( 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), - ) + 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 ConstraintListItemLarge( + modifier: Modifier = Modifier, + isReorderingEnabled: Boolean, + dragHandleModifier: Modifier, + model: ConstraintListItemModel, + onFixClick: () -> Unit, + onNotClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(8.dp)) + + if (isReorderingEnabled) { + Icon( + modifier = Modifier + .size(24.dp) + .then(dragHandleModifier), + imageVector = Icons.Rounded.DragHandle, + contentDescription = stringResource(R.string.drag_handle_for, model.text), + ) + + Spacer(Modifier.width(8.dp)) + } + + 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)) + } + } + + Spacer(Modifier.width(8.dp)) + + NotToggle(isNot = model.isNot, onClick = onNotClick) + + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + IconButton(onClick = onRemoveClick) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = Icons.Rounded.Clear, + contentDescription = stringResource(R.string.constraint_list_item_remove), + ) + } + } + } +} + +@Composable +private fun ConstraintListItemMedium( + modifier: Modifier = Modifier, + isReorderingEnabled: Boolean, + dragHandleModifier: Modifier, + model: ConstraintListItemModel, + onFixClick: () -> Unit, + onNotClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(8.dp)) + + if (isReorderingEnabled) { + Icon( + modifier = Modifier + .size(24.dp) + .then(dragHandleModifier), + imageVector = Icons.Rounded.DragHandle, + contentDescription = stringResource(R.string.drag_handle_for, model.text), + ) + + Spacer(Modifier.width(8.dp)) + } + + 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, + ) + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + NotToggle(isNot = model.isNot, onClick = onNotClick) + + if (model.error != null && model.isErrorFixable) { + CompactErrorButton(onClick = onFixClick) { + Text(stringResource(R.string.button_fix)) } } } + + 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), + ) + } + } } } @@ -229,6 +404,7 @@ private fun ConstraintIcon(modifier: Modifier = Modifier, icon: ComposeIconInfo) } @PreviewLightDark +@Preview(widthDp = 200) @Composable private fun VectorPreview() { KeyMapperTheme { @@ -254,7 +430,7 @@ private fun NotErrorPreview() { icon = ComposeIconInfo.Vector(Icons.Outlined.FlashlightOn), text = "Flashlight is on", isNot = true, - error = "Flashlight not found", + error = "Permission need to control camera", isErrorFixable = true, ), isReorderingEnabled = true, 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 de6e71b47e..c541cf0393 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 @@ -223,15 +223,6 @@ private fun ConstraintsScreen( } is ConfigConstraintsState.Loaded -> { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp), - text = stringResource(R.string.constraint_list_explanation), - style = MaterialTheme.typography.titleSmall, - textAlign = TextAlign.Center, - ) - ConstraintGroupList( modifier = Modifier.weight(1f), state = data, @@ -348,6 +339,17 @@ private fun ConstraintGroupList( state = lazyListState, contentPadding = PaddingValues(vertical = 8.dp), ) { + 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, + ) + } + itemsIndexed( orderedGroups, key = { _, group -> group.uid }, diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index a730118df0..5a58512327 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1898,7 +1898,7 @@ Are you sure you want to delete this constraint? Yes, delete Cancel - This key map will only run\nif these constraints are met. + This key map will only run if these constraints are met. NOT NOT %s Invert constraint with NOT From ef586d9aec29d037c81e86d9bd75c679321b4367 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 17 Sep 2026 21:41:49 +0200 Subject: [PATCH 032/101] #752 use error icon instead of Fix text for medium sized constraint list items --- .../base/constraints/ConstraintListItem.kt | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) 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 c002da0816..086ff53d9e 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 @@ -17,11 +17,13 @@ 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.DragHandle +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.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 @@ -190,7 +192,7 @@ private fun ConstraintListItemSmall( Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, + horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, ) { if (model.error != null && model.isErrorFixable) { @@ -325,19 +327,28 @@ private fun ConstraintListItemMedium( overflow = TextOverflow.Ellipsis, ) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - NotToggle(isNot = model.isNot, onClick = onNotClick) - + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { if (model.error != null && model.isErrorFixable) { - CompactErrorButton(onClick = onFixClick) { - Text(stringResource(R.string.button_fix)) + 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), + ) } } - } - CompositionLocalProvider( - LocalMinimumInteractiveComponentSize provides 16.dp, - ) { + Spacer(Modifier.width(8.dp)) + + NotToggle(isNot = model.isNot, onClick = onNotClick) + IconButton(onClick = onRemoveClick) { Icon( modifier = Modifier.size(24.dp), From d90bbd47a754d1100ce88a5a499097b8ffd82e99 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 17 Sep 2026 21:42:18 +0200 Subject: [PATCH 033/101] #752 share same expandable-collapsible card layout between actions and constraint groups --- .../keymapper/base/actions/ActionListItem.kt | 228 ++++---------- .../base/constraints/ConstraintGroupItem.kt | 184 +++-------- .../base/constraints/ConstraintsScreen.kt | 9 +- .../ui/compose/ExpandableDraggableCard.kt | 295 ++++++++++++++++++ 4 files changed, 402 insertions(+), 314 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ExpandableDraggableCard.kt 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 36a02c80b6..a8a0efb76d 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 @@ -14,10 +14,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkOut 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.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -31,15 +27,10 @@ import androidx.compose.material.icons.outlined.ClearAll 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.DragHandle import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.KeyboardArrowDown -import androidx.compose.material.icons.rounded.KeyboardArrowUp 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.CardDefaults -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalMinimumInteractiveComponentSize @@ -51,15 +42,10 @@ import androidx.compose.runtime.CompositionLocalProvider 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.layout.LookaheadScope 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 @@ -70,6 +56,8 @@ 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.drawable /** @@ -77,26 +65,6 @@ import io.github.sds100.keymapper.base.utils.ui.drawable */ private const val DISABLED_ALPHA = 0.38f -private 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. - */ -private val expandTransition: EnterTransition = - fadeIn(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) + - expandVertically( - animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), - expandFrom = Alignment.Top, - ) - -private val collapseTransition: ExitTransition = - fadeOut(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) + - shrinkVertically( - animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), - shrinkTowards = Alignment.Top, - ) - /** * 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. @@ -161,155 +129,65 @@ fun ActionListItem( onMoveUp: (() -> Unit)? = null, onMoveDown: (() -> Unit)? = null, ) { - 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) - // 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), ) - val cardColors = CardDefaults.elevatedCardColors( - containerColor = if (isDragging) { - MaterialTheme.colorScheme.surfaceContainerHighest - } else { - MaterialTheme.colorScheme.surfaceContainer - }, + 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)) + + // The summary shrinks while the rename button grows, so the header's natural + // height dips before reaching its final height. Animate straight to the final + // size instead so the drag handle, icon and chevron do not bounce. + HeaderText( + modifier = Modifier + .weight(1f) + .animateBounds( + lookaheadScope = this, + modifier = Modifier.padding(vertical = 8.dp), + boundsTransform = headerBoundsTransform, + ), + 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, + onEnabledChange = onEnabledChange, + ) + }, ) - - 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 { - 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(model.id) - }, - onDragStopped = { dragDropState?.onDragInterrupted() }, - ), - imageVector = Icons.Rounded.DragHandle, - contentDescription = stringResource( - R.string.drag_handle_for, - model.title, - ), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - - Spacer(Modifier.width(8.dp)) - - ActionIcon(modifier = Modifier.size(20.dp), icon = model.icon) - - Spacer(Modifier.width(8.dp)) - - // The summary shrinks while the rename button grows, so the header's natural - // height dips before reaching its final height. Animate straight to the final - // size instead so the drag handle, icon and chevron do not bounce. - HeaderText( - modifier = Modifier - .weight(1f) - .animateBounds( - lookaheadScope = this@LookaheadScope, - modifier = Modifier.padding(vertical = 8.dp), - boundsTransform = headerBoundsTransform, - ), - model = model, - isExpanded = isExpanded, - onRenameClick = onRenameClick, - ) - - CompositionLocalProvider( - LocalMinimumInteractiveComponentSize provides 16.dp, - ) { - IconButton(onClick = { onExpandedChange(!isExpanded) }) { - Icon( - imageVector = if (isExpanded) { - Icons.Rounded.KeyboardArrowUp - } else { - Icons.Rounded.KeyboardArrowDown - }, - contentDescription = if (isExpanded) { - stringResource(R.string.action_list_item_collapse) - } else { - stringResource(R.string.action_list_item_expand) - }, - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } - - Spacer(Modifier.width(4.dp)) - } - - AnimatedVisibility( - visible = isExpanded, - enter = expandTransition, - exit = collapseTransition, - ) { - 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, - onEnabledChange = onEnabledChange, - ) - } - } - } - } } @Composable diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt index 4cf56591de..e3407b9d3a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt @@ -1,8 +1,5 @@ package io.github.sds100.keymapper.base.constraints -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState @@ -10,7 +7,6 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -20,11 +16,6 @@ import androidx.compose.material.icons.outlined.Edit 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.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.MaterialTheme @@ -39,14 +30,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.res.pluralStringResource 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.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -57,12 +44,14 @@ 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.ExpandableDraggableCard @Composable fun ConstraintGroupItem( modifier: Modifier = Modifier, model: ConstraintGroupListItemModel, isExpanded: Boolean, + isDraggingEnabled: Boolean = false, isDragging: Boolean = false, isReorderingEnabled: Boolean = false, dragDropState: DragDropState? = null, @@ -78,13 +67,6 @@ fun ConstraintGroupItem( onMoveUp: (() -> Unit)? = null, onMoveDown: (() -> Unit)? = null, ) { - 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 countBasedTitle = when { model.constraints.size == 1 -> stringResource(R.string.constraint_group_title_single) @@ -103,126 +85,58 @@ fun ConstraintGroupItem( val title = model.name ?: countBasedTitle - ElevatedCard( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .semantics { - if (isReorderingEnabled) { - customActions = buildList { - onMoveUp?.let { action -> - add( - CustomAccessibilityAction(moveUpLabel) { - action() - true - }, - ) - } - onMoveDown?.let { action -> - add( - CustomAccessibilityAction(moveDownLabel) { - action() - true - }, - ) - } - } - } - }, - colors = CardDefaults.elevatedCardColors( - containerColor = if (isDragging) { - MaterialTheme.colorScheme.surfaceContainerHighest - } else { - MaterialTheme.colorScheme.surfaceContainer - }, - ), - ) { - Column(modifier = Modifier.animateContentSize()) { - Row( + 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 - .fillMaxWidth() - .heightIn(min = 56.dp) - .clickable { onExpandedChange(!isExpanded) } + .weight(1f) .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(Modifier.width(8.dp)) - - if (isReorderingEnabled) { - Icon( - modifier = Modifier - .size(24.dp) - .draggable( - state = draggableState, - orientation = Orientation.Vertical, - startDragImmediately = true, - onDragStarted = { - dragDropState?.onDragStart(model.uid) - }, - onDragStopped = { dragDropState?.onDragInterrupted() }, - ), - imageVector = Icons.Rounded.DragHandle, - contentDescription = stringResource(R.string.drag_handle_for, title), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - - Spacer(Modifier.width(8.dp)) - - HeaderText( - modifier = Modifier.weight(1f), - title = title, - model = model, - isExpanded = isExpanded, - ) - - IconButton(onClick = onRenameClick) { - Icon( - imageVector = Icons.Outlined.Edit, - contentDescription = stringResource(R.string.constraint_group_rename), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - - IconButton(onClick = { onExpandedChange(!isExpanded) }) { - Icon( - imageVector = if (isExpanded) { - Icons.Rounded.KeyboardArrowUp - } else { - Icons.Rounded.KeyboardArrowDown - }, - contentDescription = if (isExpanded) { - stringResource(R.string.constraint_group_collapse) - } else { - stringResource(R.string.constraint_group_expand) - }, - tint = MaterialTheme.colorScheme.onSurface, - ) - } - - Spacer(Modifier.width(4.dp)) - } + title = title, + model = model, + isExpanded = expanded, + ) - AnimatedVisibility(visible = isExpanded) { - 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, - onMoveConstraint = onMoveConstraint, + IconButton(onClick = onRenameClick) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = Icons.Outlined.Edit, + contentDescription = stringResource(R.string.constraint_group_rename), + tint = MaterialTheme.colorScheme.onSurface, ) } - } - } + }, + 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, + onMoveConstraint = onMoveConstraint, + ) + }, + ) } @Composable 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 c541cf0393..926158d9cc 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 @@ -304,7 +304,7 @@ private fun ConstraintGroupList( val groupUids = state.groups.map { it.uid } // Lists rather than sets so they can be saved in a Bundle. - var expandedUids by rememberSaveable { mutableStateOf(groupUids) } + var expandedUids by rememberSaveable { mutableStateOf(emptyList()) } var knownGroupUids by rememberSaveable { mutableStateOf(groupUids) } // Expand groups that are added so the user can see the new constraint. @@ -323,8 +323,6 @@ private fun ConstraintGroupList( lazyListState = lazyListState, keys = groupUids, onMove = onMoveGroup, - // Collapse all the groups so they are a similar height while dragging. - onStart = { expandedUids = emptyList() }, ) val orderedGroups = dragDropState.ordered(state.groups) { it.uid } @@ -368,9 +366,12 @@ private fun ConstraintGroupList( ) { isDragging -> Column { ConstraintGroupItem( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), model = group, isExpanded = group.uid in expandedUids, + isDraggingEnabled = orderedGroups.size > 1, isDragging = isDragging, isReorderingEnabled = state.groups.size > 1, dragDropState = dragDropState, 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", + ) + }, + ) + } +} From ae1f8cfd94d6208bf5801d113368940f032ac052 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 17 Sep 2026 21:52:26 +0200 Subject: [PATCH 034/101] #1479 feat: duplicate actions --- CHANGELOG.md | 1 + .../keymapper/base/actions/ActionListItem.kt | 12 +++++++++++ .../keymapper/base/actions/ActionsScreen.kt | 2 ++ .../base/actions/ConfigActionsUseCase.kt | 16 +++++++++++++++ .../base/actions/ConfigActionsViewModel.kt | 4 ++++ base/src/main/res/values/strings.xml | 1 + .../base/actions/ConfigActionsUseCaseTest.kt | 20 +++++++++++++++++++ 7 files changed, 56 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e5eaa89e1..bbd824e34c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - #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. 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 a8a0efb76d..5eb8161db7 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 @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width 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 @@ -124,6 +125,7 @@ fun ActionListItem( onRemoveClick: () -> Unit = {}, onFixClick: () -> Unit = {}, onTestClick: () -> Unit = {}, + onDuplicateClick: () -> Unit = {}, onRenameClick: () -> Unit = {}, onEnabledChange: (Boolean) -> Unit = {}, onMoveUp: (() -> Unit)? = null, @@ -184,6 +186,7 @@ fun ActionListItem( onRemoveClick = onRemoveClick, onFixClick = onFixClick, onTestClick = onTestClick, + onDuplicateClick = onDuplicateClick, onEnabledChange = onEnabledChange, ) }, @@ -284,6 +287,7 @@ private fun ExpandedContent( onRemoveClick: () -> Unit, onFixClick: () -> Unit, onTestClick: () -> Unit, + onDuplicateClick: () -> Unit, onEnabledChange: (Boolean) -> Unit, ) { Column( @@ -356,6 +360,14 @@ private fun ExpandedContent( Spacer(Modifier.weight(1f)) + IconButton(onClick = onDuplicateClick) { + Icon( + imageVector = Icons.Outlined.ContentCopy, + contentDescription = stringResource(R.string.action_list_item_duplicate), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + IconButton(onClick = onTestClick) { Icon( imageVector = Icons.Outlined.PlayArrow, 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 c20758bca4..5182021ab5 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 @@ -240,6 +240,7 @@ interface ActionListCallback { 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 { } @@ -355,6 +356,7 @@ private fun ActionList( 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) { 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 d63b6c8024..2623ab8a53 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 @@ -13,6 +13,7 @@ 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 @@ -83,6 +84,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 -> @@ -280,6 +295,7 @@ interface ConfigActionsUseCase : GetDefaultKeyMapOptionsUseCase { fun addAction(data: ActionData) fun moveAction(fromIndex: Int, toIndex: Int) fun removeAction(uid: String) + fun duplicateAction(uid: String) val recentlyUsedActions: Flow> fun setActionData(uid: String, data: ActionData) 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 2e56831119..c754c261ed 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 @@ -186,6 +186,10 @@ class ConfigActionsViewModel @Inject constructor( } } + override fun onDuplicateClick(actionUid: String) { + config.duplicateAction(actionUid) + } + override fun onEditClick() { val actionUid = actionOptionsUid.value ?: return viewModelScope.launch { diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 5a58512327..096aaa1797 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1825,6 +1825,7 @@ Expand Collapse Rename + Duplicate Add delay Remove No repeat 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 ff013bacdb..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 @@ -19,6 +19,7 @@ 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 @@ -154,6 +155,25 @@ class ConfigActionsUseCaseTest { 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. From 0c279f8aebea50eca1ac44f3e39fb8cdfa93be0d Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 17 Sep 2026 22:18:57 +0200 Subject: [PATCH 035/101] fix: make buttons on expanded action horizontally scrollable --- .../keymapper/base/actions/ActionListItem.kt | 109 +++++++++++++----- .../utils/ui/compose/HorizontalFadingEdges.kt | 52 +++++++++ 2 files changed, 133 insertions(+), 28 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/HorizontalFadingEdges.kt 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 5eb8161db7..fc2ea601d9 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 @@ -14,6 +14,7 @@ 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 @@ -22,6 +23,7 @@ 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.ClearAll import androidx.compose.material.icons.outlined.ContentCopy @@ -40,6 +42,7 @@ 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 @@ -50,6 +53,7 @@ import androidx.compose.ui.res.stringResource 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 @@ -59,6 +63,7 @@ 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 /** @@ -358,38 +363,60 @@ private fun ExpandedContent( Switch(checked = model.isEnabled, onCheckedChange = onEnabledChange) - Spacer(Modifier.weight(1f)) + Spacer(Modifier.width(16.dp)) - IconButton(onClick = onDuplicateClick) { - Icon( - imageVector = Icons.Outlined.ContentCopy, - contentDescription = stringResource(R.string.action_list_item_duplicate), - tint = MaterialTheme.colorScheme.onSurface, - ) - } + val scrollState = rememberScrollState() - IconButton(onClick = onTestClick) { - Icon( - imageVector = Icons.Outlined.PlayArrow, - contentDescription = stringResource(R.string.action_list_item_test), - tint = MaterialTheme.colorScheme.onSurface, - ) + // Scroll to the end initially so the delete button is always visible without + // needing to scroll. + LaunchedEffect(Unit) { + scrollState.scrollTo(scrollState.maxValue) } - IconButton(onClick = onEditClick) { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = stringResource(R.string.action_list_item_edit), - tint = MaterialTheme.colorScheme.onSurface, - ) - } + Row( + modifier = Modifier + .weight(1f) + .horizontalFadingEdges(scrollState) + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.End, + ) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + IconButton(onClick = onDuplicateClick) { + Icon( + imageVector = Icons.Outlined.ContentCopy, + contentDescription = stringResource( + R.string.action_list_item_duplicate, + ), + tint = MaterialTheme.colorScheme.onSurface, + ) + } - IconButton(onClick = onRemoveClick) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = stringResource(R.string.action_list_item_remove), - tint = MaterialTheme.colorScheme.error, - ) + IconButton(onClick = onTestClick) { + Icon( + imageVector = Icons.Outlined.PlayArrow, + contentDescription = stringResource(R.string.action_list_item_test), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + IconButton(onClick = onEditClick) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(R.string.action_list_item_edit), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + IconButton(onClick = onRemoveClick) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.action_list_item_remove), + tint = MaterialTheme.colorScheme.error, + ) + } + } } } } @@ -490,7 +517,7 @@ private fun CollapsedOneLinePreview() { } } -@Preview +@PreviewLightDark @Composable private fun ExpandedPreview() { KeyMapperTheme { @@ -516,6 +543,32 @@ private fun ExpandedPreview() { } } +@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 ExpandedNoErrorPreview() { 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, + ) + } + } From a1780832a4c6f058323f3154bdd4b0db8da84c75 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 17 Sep 2026 22:19:23 +0200 Subject: [PATCH 036/101] fix: animate constraint group item header when expanding/collapsing --- .../base/constraints/ConstraintGroupItem.kt | 79 ++++++++++++++----- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt index e3407b9d3a..05c4459c9c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt @@ -1,5 +1,14 @@ 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.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.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState @@ -44,8 +53,33 @@ 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 +/** + * 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, + ) + @Composable fun ConstraintGroupItem( modifier: Modifier = Modifier, @@ -147,29 +181,34 @@ private fun HeaderText( isExpanded: Boolean, ) { Column(modifier = modifier) { - if (isExpanded) { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } else { - Text( - text = model.name ?: model.description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) - if (model.error != null) { + AnimatedVisibility( + visible = !isExpanded, + enter = summaryEnterTransition, + exit = summaryExitTransition, + ) { + Column { Text( - text = model.error, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error, + text = model.description, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) + + if (model.error != null) { + Text( + text = model.error, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } } } } From e03b54181714bb8c4da4a6ba695a3e31931de3c7 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 17 Sep 2026 22:38:48 +0200 Subject: [PATCH 037/101] fix: use horizontal scroll for icon buttons in constraint group and align rename button next to title --- .../base/constraints/ConstraintGroupItem.kt | 144 ++++++++++++------ 1 file changed, 101 insertions(+), 43 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt index 05c4459c9c..1b7350db6c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt @@ -5,13 +5,17 @@ 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.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.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState +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 @@ -19,17 +23,21 @@ 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.Edit 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.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableFloatStateOf @@ -55,6 +63,7 @@ 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 @@ -80,6 +89,23 @@ private val summaryExitTransition: ExitTransition = 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, @@ -138,19 +164,16 @@ fun ConstraintGroupItem( modifier = Modifier .weight(1f) .padding(vertical = 8.dp), - title = title, + title = if (isExpanded) { + model.name ?: countBasedTitle + } else { + model.name + ?: model.description + }, model = model, isExpanded = expanded, + onRenameClick = onRenameClick, ) - - IconButton(onClick = onRenameClick) { - Icon( - modifier = Modifier.size(20.dp), - imageVector = Icons.Outlined.Edit, - contentDescription = stringResource(R.string.constraint_group_rename), - tint = MaterialTheme.colorScheme.onSurface, - ) - } }, expandedContent = { ExpandedContent( @@ -179,36 +202,49 @@ private fun HeaderText( title: String, model: ConstraintGroupListItemModel, isExpanded: Boolean, + onRenameClick: () -> Unit, ) { Column(modifier = modifier) { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + modifier = Modifier + .weight(1f, fill = false), + 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, ) { - Column { + if (model.error != null) { Text( - text = model.description, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + text = model.error, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, ) - - if (model.error != null) { - Text( - text = model.error, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error, - ) - } } } } @@ -269,20 +305,42 @@ private fun ExpandedContent( Spacer(Modifier.weight(1f)) - IconButton(onClick = onAddConstraintClick) { - Icon( - imageVector = Icons.Rounded.Add, - contentDescription = stringResource(R.string.constraint_group_add_constraint), - tint = MaterialTheme.colorScheme.onSurface, - ) + 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) } - IconButton(onClick = onDeleteClick) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = stringResource(R.string.constraint_group_delete), - tint = MaterialTheme.colorScheme.error, - ) + 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, + ) + } + } } } } From 91143a2fb478058e2a33dfcd160a41a410cf5c6f Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 17 Sep 2026 22:51:33 +0200 Subject: [PATCH 038/101] #2139 #1987 feat: color code groups if there are errors or disabled --- CHANGELOG.md | 2 + .../base/groups/GroupListItemModel.kt | 8 +- .../sds100/keymapper/base/groups/GroupRow.kt | 125 +++++++++++++++--- .../keymapper/base/groups/GroupWithState.kt | 3 + .../sds100/keymapper/base/home/KeyMapGroup.kt | 3 +- .../base/home/KeyMapListViewModel.kt | 12 +- .../keymapper/base/home/ListKeyMapsUseCase.kt | 78 ++++++++++- .../CreateKeyMapShortcutViewModel.kt | 2 +- 8 files changed, 198 insertions(+), 35 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/groups/GroupWithState.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd824e34c..e5c75321ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ - #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. ## [4.4.1](https://github.com/sds100/KeyMapper/releases/tag/v4.4.1) 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..62ecbc641b 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 @@ -135,6 +135,8 @@ fun GroupRow( onClick = { onGroupClick(group.uid) }, text = group.name, enabled = enabled, + isGroupEnabled = group.isEnabled, + isGroupError = group.isError, icon = { when (group.icon) { is ComposeIconInfo.Drawable -> { @@ -275,29 +277,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 +349,64 @@ 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 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/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/KeyMapListViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt index 9633aca76a..0ce48e8839 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 @@ -14,9 +14,9 @@ import io.github.sds100.keymapper.base.backup.RestoreType 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 @@ -465,18 +465,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.constraints.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, ) } 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 d08b47e45b..3439384a29 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 @@ -39,6 +42,7 @@ 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 @@ -88,15 +92,17 @@ class ListKeyMapsUseCaseImpl @Inject constructor( override val keyMapGroup: Flow = channelFlow { keyMapListGroupUid .flatMapLatest(::getGroupFamily) - .map { groupFamily -> + .flatMapLatest { groupFamily -> val parentGroups = getParentsRecursively(groupFamily.group?.uid) - KeyMapGroup( - group = groupFamily.group, - subGroups = groupFamily.children, - keyMaps = State.Loading, - parents = parentGroups, - ) + getSubGroupsWithState(groupFamily.children).map { subGroups -> + KeyMapGroup( + group = groupFamily.group, + subGroups = subGroups, + keyMaps = State.Loading, + parents = parentGroups, + ) + } }.onEach { send(it) } .flatMapLatest { keyMapGroup -> getKeyMapsByGroup(keyMapGroup.group?.uid).map { keyMapGroup.copy(keyMaps = it) } @@ -105,6 +111,64 @@ class ListKeyMapsUseCaseImpl @Inject constructor( } } + /** + * 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.constraints.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.constraints.any { + constraintErrorSnapshot.getError(it) != null + } + ) + } + + return GroupWithState( + group = group, + isEnabled = isEnabled, + isError = hasOwnConstraintError || hasEnabledKeyMapError, + ) + } + override val selectionGroupFamily: Flow = selectionGroupUid.flatMapLatest(::getGroupFamily) 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..e511b858a5 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 @@ -120,7 +120,7 @@ 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() From 1a329ff7984b861c0287f6358b797065423c46d3 Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 18 Sep 2026 11:05:05 +0200 Subject: [PATCH 039/101] #2246 feat: adjust padding and font size in trigger screen to match the new actions and constraints screens --- CHANGELOG.md | 2 + .../base/trigger/BaseTriggerScreen.kt | 70 +++++++------------ .../base/trigger/TriggerKeyListItem.kt | 46 ++++++------ .../trigger/TriggerKeyOptionsBottomSheet.kt | 6 +- 4 files changed, 50 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c75321ce..0db73bfc10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Added +- #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. @@ -17,6 +18,7 @@ - #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. ## [4.4.1](https://github.com/sds100/KeyMapper/releases/tag/v4.4.1) 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 c7f7d63801..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 @@ -31,8 +30,8 @@ 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 @@ -116,8 +115,6 @@ fun BaseTriggerScreen( is State.Data -> { val tipContent: @Composable () -> Unit = { tipModel?.let { tip -> - Spacer(Modifier.height(8.dp)) - TipCard( modifier = Modifier .fillMaxWidth() @@ -130,7 +127,7 @@ fun BaseTriggerScreen( onButtonClick = { viewModel.onTipButtonClick(tip.id) }, ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) } } @@ -249,8 +246,6 @@ private fun TriggerScreenVertical( } is ConfigTriggerState.Loaded -> { - tipContent() - TriggerList( modifier = Modifier.weight(1f), triggerList = configState.triggerKeys, @@ -260,6 +255,7 @@ private fun TriggerScreenVertical( onMove = onMoveTriggerKey, onFixErrorClick = onFixErrorClick, onAddMoreClick = onAddMoreTriggerKeysClick, + tipContent = tipContent, ) if (configState.clickTypeButtons.isNotEmpty()) { @@ -438,6 +434,7 @@ 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 } } @@ -458,6 +455,10 @@ private fun TriggerList( contentPadding = PaddingValues(vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { + item(key = "tip", contentType = "tip") { + tipContent() + } + itemsIndexed( orderedTriggerList, key = { _, item -> item.id }, @@ -627,7 +628,7 @@ private val previewState = triggerModeButtonsVisible = true, ) -@Preview(device = Devices.PIXEL) +@PreviewLightDark @Composable private fun VerticalPreview() { KeyMapperTheme { @@ -642,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)) }, ) } @@ -727,7 +724,7 @@ private fun PreviewSquareRectangle() { } } -@Preview(device = Devices.PIXEL) +@PreviewLightDark @Composable private fun VerticalEmptyPreview() { KeyMapperTheme { @@ -746,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() { @@ -793,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/TriggerKeyListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt index adfb51b65f..5f860da434 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 @@ -24,10 +24,8 @@ 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.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 @@ -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,17 @@ fun TriggerKeyListItem( ) } + if (model.error?.isFixable ?: false) { + Spacer(Modifier.height(4.dp)) + + CompactErrorButton(onClick = { onFixClick(model.error!!) }) { + Text(text = stringResource(R.string.button_fix)) + } + } + 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( @@ -375,20 +367,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 +388,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 +406,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 24c1b11264..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 @@ -42,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 @@ -110,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, @@ -129,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, From 06f8524cd073ec345b4edc8a08543e54fe00c49b Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 18 Sep 2026 11:16:30 +0200 Subject: [PATCH 040/101] fix: do not crash if permission not granted for in phone call constraint and trying to perform key map --- CHANGELOG.md | 4 ++++ .../system/phone/AndroidPhoneAdapter.kt | 16 +++++++++++++++- .../keymapper/system/phone/PhoneAdapter.kt | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0db73bfc10..438b1e3a65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ - #1987 groups are red if any key maps inside have errors. - #2246 adjusted the trigger screen design to match the action and constraint screens. +## Fixed + +- Do not crash if permission not granted for in phone call constraint and trying to perform key map. + ## [4.4.1](https://github.com/sds100/KeyMapper/releases/tag/v4.4.1) #### TO BE RELEASED 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() From 72d3051667155d33ed3c03e076c252c9b95926f7 Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 18 Sep 2026 11:17:17 +0200 Subject: [PATCH 041/101] #2250 feat: key map options screen redesign --- .../sds100/keymapper/base/BaseMainNavHost.kt | 23 + .../github/sds100/keymapper/base/IntentApi.kt | 10 + .../base/constraints/ConstraintSnapshot.kt | 9 +- .../keymaps/ConfigKeyMapOptionsViewModel.kt | 69 ++- .../base/keymaps/EnableByIntentScreen.kt | 117 +++++ .../base/keymaps/IntentScreenComponents.kt | 127 ++++++ .../base/keymaps/KeyMapOptionsScreen.kt | 417 +++++++++++------- .../base/keymaps/TriggerByIntentScreen.kt | 87 ++++ .../shortcuts/CreateKeyMapShortcutUseCase.kt | 23 +- .../base/utils/navigation/NavDestination.kt | 12 + .../keymapper/base/utils/ui/SliderMinimums.kt | 2 +- .../base/utils/ui/compose/CodeBlock.kt | 165 +++++++ .../ui/compose/SwitchPreferenceCompose.kt | 2 +- .../utils/ui/compose/icons/MobileSensorHi.kt | 87 ++++ base/src/main/res/values/strings.xml | 47 +- 15 files changed, 987 insertions(+), 210 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/IntentApi.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/keymaps/EnableByIntentScreen.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/keymaps/IntentScreenComponents.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerByIntentScreen.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CodeBlock.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/MobileSensorHi.kt 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..1ed585d16c 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 @@ -29,6 +30,8 @@ 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 @@ -189,6 +192,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/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/constraints/ConstraintSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt index 09efcc3eb3..cac09b1167 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 @@ -69,7 +69,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 } @@ -95,6 +95,7 @@ class LazyConstraintSnapshot( override fun isSatisfied(constraint: Constraint): Boolean { val isSatisfied = when (constraint.data) { is ConstraintData.AppInForeground -> appInForeground == constraint.data.packageName + is ConstraintData.AppPlayingMedia -> appsPlayingMedia.contains(constraint.data.packageName) || (appInForeground == constraint.data.packageName && isMediaPlaying()) @@ -106,6 +107,7 @@ class LazyConstraintSnapshot( } is ConstraintData.OrientationCustom -> orientation == constraint.data.orientation + is ConstraintData.OrientationLandscape -> orientation == Orientation.ORIENTATION_90 || orientation == Orientation.ORIENTATION_270 @@ -132,6 +134,7 @@ class LazyConstraintSnapshot( ) is ConstraintData.FlashlightOn -> cameraAdapter.isFlashlightOn(constraint.data.lens) + is ConstraintData.WifiConnected -> { if (constraint.data.ssid == null) { // connected to any network @@ -142,9 +145,13 @@ class LazyConstraintSnapshot( } is ConstraintData.WifiOn -> isWifiEnabled + is ConstraintData.ImeChosen -> chosenImeId == constraint.data.imeId + is ConstraintData.KeyboardShowing -> isKeyboardShowing + is ConstraintData.DeviceIsLocked -> isLocked + is ConstraintData.InPhoneCall -> callState == CallState.IN_PHONE_CALL || audioVolumeStreams.contains(AudioManager.STREAM_VOICE_CALL) 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..bac571be9a 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,9 @@ 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.common.utils.State import io.github.sds100.keymapper.common.utils.dataOrNull import io.github.sds100.keymapper.common.utils.mapData @@ -47,6 +47,9 @@ class ConfigKeyMapOptionsViewModel( private val actionUiHelper = ActionUiHelper(displayUseCase, resourceProvider) + var createShortcutDialogState: CreateShortcutDialogState? by mutableStateOf(null) + private set + val state: StateFlow> = combine( config.keyMap, systemBridgeConnectionManager.connectionState, @@ -92,12 +95,25 @@ class ConfigKeyMapOptionsViewModel( } } + 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 +147,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, + ) + } + } + + suspend fun onConfirmCreateShortcut(name: String): String? { + val dialogState = createShortcutDialogState ?: return null - val result = createKeyMapShortcut.pinShortcut(keyMapUid, shortcutName, icon) + val result = createKeyMapShortcut.pinShortcut(dialogState.keyMapUid, name, dialogState.icon) - result.onFailure { error -> - val snackBar = DialogModel.SnackBar( - message = error.getFullMessage(this@ConfigKeyMapOptionsViewModel), - ) + var errorMessage: String? = null - showDialog("create_shortcut_result", snackBar) - } + result.onFailure { error -> + errorMessage = error.getFullMessage(this@ConfigKeyMapOptionsViewModel) } + + if (errorMessage == null) { + createShortcutDialogState = null + } + + return errorMessage + } + + fun onDismissCreateShortcutDialog() { + createShortcutDialogState = null } private suspend fun buildState( @@ -197,6 +222,12 @@ class ConfigKeyMapOptionsViewModel( } } +data class CreateShortcutDialogState( + val keyMapUid: String, + val defaultName: String, + val icon: Drawable?, +) + data class KeyMapOptionsState( val showLongPressDelay: Boolean, val longPressDelay: Int, 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/KeyMapOptionsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt index e2c7e887f0..57daf45614 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,10 +14,17 @@ 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.Route +import androidx.compose.material.icons.outlined.Science +import androidx.compose.material.icons.outlined.Sensors +import androidx.compose.material.icons.outlined.Terminal +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.material3.CircularProgressIndicator -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -31,25 +36,25 @@ 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 import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme -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.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 +67,18 @@ 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, + ) + } } @Composable @@ -98,54 +115,40 @@ 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), - title = stringResource(R.string.tip_screen_off_trigger_title), - message = stringResource(R.string.tip_screen_off_trigger_message), - isDismissable = false, - buttonText = if (state.isExpertModeStarted) { - null - } else { - stringResource(R.string.button_enable_expert_mode) - }, - onButtonClick = callback::onOpenExpertModeSettings, - ) - } + OptionsHeaderRow( + icon = Icons.Outlined.Info, + text = stringResource(R.string.key_map_options_header_information), + ) - TriggerFromOtherAppsSection( + KeyMapUidRow( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - isChecked = state.triggerFromOtherApps, - onCheckedChange = callback::onTriggerFromOtherAppsChanged, - isLauncherShortcutEnabled = state.isLauncherShortcutButtonEnabled, + .fillMaxWidth(), keyMapUid = state.keyMapUid, - onCreateShortcutClick = callback::onCreateShortcutClick, ) - CheckBoxText( - modifier = Modifier - .padding(horizontal = 8.dp) - .fillMaxWidth(), - text = stringResource(R.string.flag_show_toast), - isChecked = state.showToast, - onCheckedChange = callback::onShowToastChanged, + ScreenOffSection( + modifier = Modifier.fillMaxWidth(), + isEvdevTrigger = !state.showScreenOffTip, + isExpertModeStarted = state.isExpertModeStarted, + onEnableExpertModeClick = callback::onOpenExpertModeSettings, + ) + + 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), + SwitchPreferenceCompose( + 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, ) @@ -156,8 +159,7 @@ private fun Loaded( val vibrateDurationMax = SliderMaximums.VIBRATION_DURATION SliderOptionText( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + .fillMaxWidth(), title = stringResource(R.string.extra_label_vibration_duration), defaultValue = state.defaultVibrateDuration.toFloat(), value = state.vibrateDuration.toFloat(), @@ -169,23 +171,42 @@ private fun Loaded( } 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 +222,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 +240,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,128 +251,141 @@ 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.Outlined.Terminal, + onClick = callback::onEnableByIntentClick, + ) + SwitchPreferenceCompose( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.key_map_options_trigger_from_other_apps_title), + text = stringResource(R.string.key_map_options_trigger_from_other_apps_summary), + icon = Icons.Outlined.Route, + isChecked = state.triggerFromOtherApps, + onCheckedChange = callback::onTriggerFromOtherAppsChanged, + ) + + 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.Terminal, + // Key Mapper only listens for these intents when other apps are allowed to + // control this key map. + enabled = state.triggerFromOtherApps, + 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.triggerFromOtherApps && 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, - ) +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( - modifier = Modifier.padding(horizontal = 12.dp), + Text( + text = keyMapUid, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - text = stringResource(R.string.flag_trigger_from_other_apps), - style = MaterialTheme.typography.bodyLarge, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val clipboardLabel = stringResource(R.string.clipboard_label_keymap_uid) + + 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, + ), + ) } + } +} - 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, - ) +@Composable +private fun ScreenOffSection( + modifier: Modifier = Modifier, + isEvdevTrigger: Boolean, + isExpertModeStarted: Boolean, + onEnableExpertModeClick: () -> Unit, +) { + Column(modifier) { + OptionsHeaderRow( + icon = Icons.Outlined.Science, + text = stringResource(R.string.key_map_options_header_screen_off), + ) - 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)) - 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) - } + SwitchPreferenceCompose( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.key_map_options_screen_off_title), + // Explain how to fix this when the trigger was not recorded with Expert Mode. + text = if (isEvdevTrigger) { + null + } else { + stringResource(R.string.tip_screen_off_trigger_message) + }, + icon = Icons.Outlined.Science, + isChecked = isEvdevTrigger, + onCheckedChange = {}, + // This cannot be turned on and off here. It depends on how the trigger was recorded. + isEnabled = false, + ) - 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, - ), - ) - } + if (!isExpertModeStarted) { + Spacer(Modifier.height(8.dp)) + + OutlinedButton( + modifier = Modifier + .align(Alignment.End) + .padding(horizontal = 16.dp), + onClick = onEnableExpertModeClick, + ) { + Text(stringResource(R.string.button_enable_expert_mode)) } } } @@ -370,6 +402,8 @@ interface KeyMapOptionsCallback { fun onTriggerFromOtherAppsChanged(checked: Boolean) = run {} fun onCreateShortcutClick() = run { } fun onOpenExpertModeSettings() = run {} + fun onTriggerByIntentClick() = run {} + fun onEnableByIntentClick() = run {} } @Preview @@ -403,7 +437,7 @@ private fun Preview() { longPressDoubleVibration = false, triggerFromOtherApps = true, - keyMapUid = "00000-00000-00000-0000000000000000000000000000000000", + keyMapUid = "beea7ef5-e33e-4bd3-9987-9002e5035f23", isLauncherShortcutButtonEnabled = false, showToast = true, @@ -416,3 +450,48 @@ 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, + + showVibrateDuration = false, + vibrateDuration = 100, + defaultVibrateDuration = 100, + + showVibrate = true, + vibrate = false, + + showLongPressDoubleVibration = false, + longPressDoubleVibration = false, + + triggerFromOtherApps = 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/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/utils/navigation/NavDestination.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt index 4849afedb6..536a4390c8 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 @@ -47,6 +47,8 @@ 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" } @Serializable @@ -223,4 +225,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/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/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/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/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/res/values/strings.xml b/base/src/main/res/values/strings.xml index 096aaa1797..3693418844 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -371,7 +371,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 @@ -456,9 +455,6 @@ Add constraint Choose Key code Add extra - Create launcher shortcut - Create the shortcut manually - Intent guide Help Select screenshot (optional) Choose activity @@ -485,7 +481,6 @@ There is a timeout to input this trigger. You can change this timeout in the "Options" tab. How to use this trigger Sequence triggers - Trigger when screen is off This key map cannot be detected when the screen is off because the trigger was not recorded with Expert Mode. Re-record the trigger with Expert Mode to enable screen-off remapping. Android doesn\'t allow apps to get a list of connected (not paired) Bluetooth devices. Apps can only detect when they are connected and disconnected. So if your Bluetooth device is already connected to your device when the accessibility service starts, you will have to reconnect it for the app to know it is connected. Automatic backup @@ -2218,4 +2213,46 @@ 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 + Allow other apps to control this key map + Required to trigger this key map with an intent or a shortcut + 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 + From eb76c27c1ee142ad1ee8168d00d7a3f583fab49b Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 18 Sep 2026 11:40:57 +0200 Subject: [PATCH 042/101] fix padding in TriggerKeyListItem --- .../github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5f860da434..c3e941e29b 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 @@ -194,7 +194,7 @@ fun TriggerKeyListItem( } if (model.error?.isFixable ?: false) { - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.width(8.dp)) CompactErrorButton(onClick = { onFixClick(model.error!!) }) { Text(text = stringResource(R.string.button_fix)) From 6710faca123db1abd8d70f4948b428344d057496 Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 18 Sep 2026 11:41:55 +0200 Subject: [PATCH 043/101] #1501 feat: new action to cycle through your enabled keyboards --- CHANGELOG.md | 1 + .../keymapper/base/actions/ActionData.kt | 5 ++ .../base/actions/ActionDataEntityMapper.kt | 3 + .../sds100/keymapper/base/actions/ActionId.kt | 1 + .../keymapper/base/actions/ActionUiHelper.kt | 2 + .../keymapper/base/actions/ActionUtils.kt | 8 ++ .../base/actions/CreateActionDelegate.kt | 2 + .../base/actions/PerformActionsUseCase.kt | 17 ++++ base/src/main/res/values/strings.xml | 1 + .../base/actions/PerformActionsUseCaseTest.kt | 84 ++++++++++++++++++- .../inputmethod/FakeInputMethodAdapter.kt | 14 ++++ .../inputmethod/AndroidInputMethodAdapter.kt | 14 ++++ .../system/inputmethod/InputMethodAdapter.kt | 6 ++ 13 files changed, 156 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 438b1e3a65..f8db5a9154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Added +- #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. 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..0edf3eafb2 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 @@ -251,6 +251,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() { 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..08b3ee195e 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 @@ -645,6 +645,8 @@ object ActionDataEntityMapper { ActionId.CYCLE_KEYBOARD_LANGUAGE -> ActionData.CycleKeyboardLanguage + ActionId.CYCLE_KEYBOARD -> ActionData.CycleKeyboard + ActionId.TEXT_CUT -> ActionData.CutText ActionId.TEXT_COPY -> ActionData.CopyText @@ -1600,6 +1602,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/ActionId.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt index 3dd5c08179..dc034e2442 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, 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 1296d5b774..e97033d141 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 @@ -736,6 +736,8 @@ class ActionUiHelper( 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) 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..a22841a24f 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 @@ -231,6 +231,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 @@ -439,6 +440,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 @@ -912,6 +915,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, @@ -1062,6 +1069,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 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..b83813530f 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 @@ -1172,6 +1172,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 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..edd4ae89c2 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 @@ -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, diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 3693418844..1a00f447af 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1157,6 +1157,7 @@ Switch keyboard Switch to %s Cycle keyboard language + Cycle through keyboards Cut Copy 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..2ca153ad8d 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(), @@ -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/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/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 } From 604647ecba70f2e3cbede20b6103d4ea3a1e5fab Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 18 Sep 2026 11:51:52 +0200 Subject: [PATCH 044/101] fix running mockito tests on JDK 25 bundled with Android Studio --- base/build.gradle.kts | 1 - gradle/libs.versions.toml | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index bb38faaad3..cd18a7178b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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 From abfa3b8a2d84d99eeec6686b3bbae7de5d941a44 Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 18 Sep 2026 11:51:52 +0200 Subject: [PATCH 045/101] fix running mockito tests on JDK 25 bundled with Android Studio --- base/build.gradle.kts | 1 - gradle/libs.versions.toml | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index bb38faaad3..cd18a7178b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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 From f5e9b743342f0a298cfee4d980151c1d0c2e721a Mon Sep 17 00:00:00 2001 From: Seth Schroeder Date: Fri, 18 Sep 2026 13:46:10 +0200 Subject: [PATCH 046/101] add trendshift badge to readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 446accb2e2..109931dc00 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@

+

+keymapperorg%2FKeyMapper | Trendshift +

+

Get it on Google Play Get it on F-Droid From 568f9a239b60efbfe4943aaf85c17c2bdb5637ee Mon Sep 17 00:00:00 2001 From: Seth Schroeder Date: Fri, 18 Sep 2026 13:50:36 +0200 Subject: [PATCH 047/101] Update strings.xml --- base/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index bc6ba9d952..214e86ff29 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1187,7 +1187,7 @@ Play sound Dismiss most recent notification Dismiss all notifications - Create notification + Show notification Show notification: %1$s Notification title Enter notification title @@ -1201,7 +1201,7 @@ Test Testing… Notification shown successfully - Show toast + Show on-screen message Show short toast: %1$s Show long toast: %1$s Toast message From eb758a266205bdbe50aa78d7e07a44d630cacab9 Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 18 Sep 2026 14:41:29 +0200 Subject: [PATCH 048/101] refactor: move AndroidNotificationAdapter to system module and use that instead of NotificationReceiverAdapter in PerformActionUseCase --- .../keymapper/base/BaseSingletonHiltModule.kt | 6 -- .../base/actions/PerformActionsUseCase.kt | 13 +--- .../base/actions/PerformActionsUseCaseTest.kt | 1 - system/build.gradle.kts | 2 + .../keymapper/system/SystemHiltModule.kt | 6 ++ .../AndroidNotificationAdapter.kt | 60 ++++++++++++++++--- .../notifications/NotificationAdapter.kt | 11 ++++ system/src/main/res/values-night/colors.xml | 5 ++ system/src/main/res/values/colors.xml | 9 +++ 9 files changed, 87 insertions(+), 26 deletions(-) rename {base/src/main/java/io/github/sds100/keymapper/base => system/src/main/java/io/github/sds100/keymapper}/system/notifications/AndroidNotificationAdapter.kt (82%) create mode 100644 system/src/main/res/values-night/colors.xml create mode 100644 system/src/main/res/values/colors.xml 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/actions/PerformActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt index edd4ae89c2..3beebc8f1e 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 @@ -60,8 +60,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 @@ -116,7 +114,6 @@ 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, @@ -994,17 +991,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 -> { 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 2ca153ad8d..f4d94c9cc9 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 @@ -84,7 +84,6 @@ class PerformActionsUseCaseTest { resourceProvider = mock(), settingsRepository = mock(), soundsManager = mock(), - notificationReceiverAdapter = mock(), ringtoneAdapter = mock(), inputEventHub = mockInputEventHub, systemBridgeConnectionManager = mock(), 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/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 82% 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..e1d73b0316 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,15 @@ 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.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 +34,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 +42,14 @@ class AndroidNotificationAdapter @Inject constructor( override val onNotificationActionClick = MutableSharedFlow() override val onNotificationRemoteInput = MutableSharedFlow() + private val postedNotificationStore = PostedNotificationStore() + + override val activeNotifications: StateFlow> = + postedNotificationStore.active + + override val recentNotifications: StateFlow> = + postedNotificationStore.recent + private val broadcastReceiver: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { context ?: return @@ -82,8 +88,9 @@ 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) @@ -216,6 +223,7 @@ class AndroidNotificationAdapter @Inject constructor( is KMNotificationAction.Broadcast -> createBroadcastPendingIntent( notificationAction.intentAction.name, ) + is KMNotificationAction.RemoteInput -> createRemoteInputPendingIntent( notificationAction.intentAction.name, ) @@ -271,4 +279,40 @@ class AndroidNotificationAdapter @Inject constructor( PendingIntent.FLAG_IMMUTABLE, ) } + + override suspend fun dismissAllNotifications(): KMResult<*> = + notificationReceiverAdapter.send(NotificationServiceEvent.DismissAllNotifications) + + override suspend fun dismissLastNotification(): KMResult<*> = + notificationReceiverAdapter.send(NotificationServiceEvent.DismissLastNotification) + + /** + * Replace everything that is posted, for when the listener connects. + * + * Only for [NotificationReceiver]. + */ + fun onNotificationsSeeded(notifications: List) { + postedNotificationStore.onSeeded(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..22de986b04 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,6 +1,7 @@ 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 interface NotificationAdapter { @@ -19,4 +20,14 @@ interface NotificationAdapter { 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. + */ + suspend fun dismissLastNotification(): KMResult<*> } 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 + From 98caeb4df2590b79083c48ed289840e3fe4e8e7a Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 18 Sep 2026 15:11:25 +0200 Subject: [PATCH 049/101] gitignore .kotlin folder --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9d7b825226..00ac8bc3da 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 From aaac5b7c97e9876e90ad5f0a0d79a15657fcd078 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 19 Sep 2026 09:50:56 +0200 Subject: [PATCH 050/101] feat: set constraint group mode as OR by default --- .../base/constraints/ConfigConstraintsUseCase.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 ccab283be3..7aae9de4db 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 @@ -51,11 +51,11 @@ class ConfigConstraintsUseCaseImpl @Inject constructor( if (group == null) { val newGroup = ConstraintGroup(constraints = listOf(newConstraint)) - // The mode between groups is not used when there is only one group and old - // constraints load it from the group mode, so groups are combined with AND by - // default when a second group is created. + // 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.AND + ConstraintMode.OR } else { oldState.mode } From 7b75a62b0e6717b7f5eddbf0dac391aeff8f9f04 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 19 Sep 2026 09:56:03 +0200 Subject: [PATCH 051/101] #1341 feat: new constraint for when a notification from a specific app, or with a specific title or text, is posted --- CHANGELOG.md | 1 + .../sds100/keymapper/base/BaseMainNavHost.kt | 11 + .../constraints/ChooseConstraintViewModel.kt | 12 + .../ConfigNotificationConstraintScreen.kt | 241 ++++++++++++++++++ .../ConfigNotificationConstraintViewModel.kt | 118 +++++++++ .../keymapper/base/constraints/Constraint.kt | 26 ++ .../base/constraints/ConstraintCategory.kt | 1 + .../base/constraints/ConstraintDependency.kt | 1 + .../constraints/ConstraintEntityMapper.kt | 63 +++++ .../constraints/ConstraintErrorSnapshot.kt | 10 + .../base/constraints/ConstraintId.kt | 1 + .../base/constraints/ConstraintSnapshot.kt | 21 ++ .../base/constraints/ConstraintUiHelper.kt | 55 ++++ .../base/constraints/ConstraintUtils.kt | 30 ++- .../constraints/DetectConstraintsUseCase.kt | 6 + .../NotificationConstraintMatcher.kt | 33 +++ .../base/constraints/NotificationField.kt | 23 ++ .../base/constraints/TextMatchMode.kt | 12 + .../KeyMapConstraintsComparator.kt | 9 + .../base/utils/navigation/NavDestination.kt | 7 + base/src/main/res/values/strings.xml | 34 +++ .../NotificationConstraintMatcherTest.kt | 84 ++++++ .../PostedNotificationStoreTest.kt | 77 ++++++ .../base/utils/TestConstraintSnapshot.kt | 29 +++ .../data/entities/ConstraintEntity.kt | 6 + .../AndroidNotificationAdapter.kt | 19 +- .../notifications/NotificationAdapter.kt | 9 +- .../notifications/NotificationReceiver.kt | 56 +++- .../notifications/NotificationServiceEvent.kt | 5 +- .../notifications/PostedNotification.kt | 15 ++ .../notifications/PostedNotificationStore.kt | 52 ++++ 31 files changed, 1044 insertions(+), 23 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintScreen.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintViewModel.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/constraints/NotificationConstraintMatcher.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/constraints/NotificationField.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/constraints/TextMatchMode.kt create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/constraints/NotificationConstraintMatcherTest.kt create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/system/notifications/PostedNotificationStoreTest.kt create mode 100644 system/src/main/java/io/github/sds100/keymapper/system/notifications/PostedNotification.kt create mode 100644 system/src/main/java/io/github/sds100/keymapper/system/notifications/PostedNotificationStore.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f8db5a9154..8180165253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## 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. 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 1ed585d16c..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 @@ -26,6 +26,8 @@ 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 @@ -104,6 +106,15 @@ fun BaseMainNavHost( ) } + composable { + val viewModel: ConfigNotificationConstraintViewModel = hiltViewModel() + + ConfigNotificationConstraintScreen( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + ) + } + composable { val viewModel: ChooseActionViewModel = hiltViewModel() 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 50a8a28bee..041ac9694b 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, ) } @@ -187,6 +188,8 @@ class ChooseConstraintViewModel @Inject constructor( ConstraintId.MEDIA_PLAYING -> returnResult.emit(ConstraintData.MediaPlaying) + ConstraintId.NOTIFICATION_POSTED -> onSelectNotificationConstraint() + ConstraintId.BT_DEVICE_CONNECTED -> onSelectBluetoothConstraint() ConstraintId.SCREEN_ON -> returnResult.emit(ConstraintData.ScreenOn) @@ -574,6 +577,15 @@ class ChooseConstraintViewModel @Inject constructor( ) } + private suspend fun onSelectNotificationConstraint() { + val constraintData = navigate( + "config_notification_constraint", + NavDestination.ConfigNotificationConstraint, + ) ?: return + + returnResult.emit(constraintData) + } + private suspend fun onSelectAppConstraint(type: ConstraintId) { val packageName = navigate( 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..aa694c2772 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintViewModel.kt @@ -0,0 +1,118 @@ +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 packageName = navigate( + "choose_app_for_notification_constraint", + NavDestination.ChooseApp(allowHiddenApps = true), + ) ?: return@launch + + value = 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) + + 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 602296b08a..9a934edb9a 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 @@ -160,6 +160,32 @@ sealed class ConstraintData { override val id: ConstraintId = ConstraintId.NOTIFICATION_PANEL_SHOWING } + @Serializable + 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) : NotificationPosted() + } + @Serializable data class Time( val startHour: Int, 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 index cde4aee3f2..6eee7f3bb1 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt @@ -198,6 +198,31 @@ object ConstraintEntityMapper { 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) + + 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()!! @@ -453,6 +478,40 @@ object ConstraintEntityMapper { 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, + ), + ) + } + }, + ) + } + is ConstraintData.Time -> ConstraintEntity( uid = constraint.uid, type = ConstraintEntity.TIME, @@ -466,4 +525,8 @@ object ConstraintEntityMapper { ), ) } + + 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 9ede18510e..416277966c 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 @@ -105,6 +105,16 @@ 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) + } + } + else -> Unit } 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 2d9da6f038..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 @@ -54,6 +54,7 @@ enum class ConstraintId { HINGE_OPEN, NOTIFICATION_PANEL_SHOWING, + NOTIFICATION_POSTED, TIME, } 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 cac09b1167..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 { @@ -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 { @@ -198,6 +205,20 @@ class LazyConstraintSnapshot( is ConstraintData.NotificationPanelShowing -> 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)) { localTime.isAfter(constraint.data.startTime) || 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 ecf2885553..fd20e18d87 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 @@ -147,6 +148,9 @@ class ConstraintUiHelper( is ConstraintData.NotificationPanelShowing -> getString(R.string.constraint_notification_panel_showing) + is ConstraintData.NotificationPosted -> + getNotificationPostedTitle(constraint.data, isNot = false) + is ConstraintData.Time -> getString( R.string.constraint_time_formatted, arrayOf( @@ -235,6 +239,9 @@ class ConstraintUiHelper( 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( @@ -291,6 +298,10 @@ class ConstraintUiHelper( is ConstraintData.AppPlayingMedia -> 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 { @@ -298,6 +309,50 @@ class ConstraintUiHelper( } } + private fun getNotificationPostedTitle( + data: ConstraintData.NotificationPosted, + isNot: Boolean, + ): String = when (data) { + is ConstraintData.NotificationPosted.FromApp -> { + val appName = getAppName(data.packageName).valueIfFailure { 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 + } + + @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? = getAppIcon(packageName).handle( onSuccess = { ComposeIconInfo.Drawable(it) }, 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 55a85cc328..01512a07be 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,6 +49,7 @@ 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 } @@ -102,7 +104,9 @@ object ConstraintUtils { ConstraintId.HINGE_OPEN, -> ConstraintCategory.DEVICE - ConstraintId.NOTIFICATION_PANEL_SHOWING -> ConstraintCategory.DISPLAY + ConstraintId.NOTIFICATION_PANEL_SHOWING, + ConstraintId.NOTIFICATION_POSTED, + -> ConstraintCategory.NOTIFICATIONS ConstraintId.TIME -> ConstraintCategory.TIME } @@ -183,6 +187,9 @@ object ConstraintUtils { ConstraintId.NOTIFICATION_PANEL_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.Notifications) + ConstraintId.NOTIFICATION_POSTED -> + ComposeIconInfo.Vector(Icons.Outlined.NotificationsActive) + ConstraintId.TIME -> ComposeIconInfo.Vector(Icons.Outlined.Timer) } @@ -219,6 +226,9 @@ object ConstraintUtils { ConstraintId.NOT_IN_PHONE_CALL -> ComposeIconInfo.Vector(Icons.Outlined.Call) + ConstraintId.NOTIFICATION_POSTED -> + ComposeIconInfo.Vector(Icons.Outlined.NotificationsOff) + else -> getIcon(constraintId) } @@ -295,9 +305,24 @@ object ConstraintUtils { ConstraintId.NOTIFICATION_PANEL_SHOWING -> R.string.constraint_notification_panel_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 -> setOf(ConstraintDependency.FOREGROUND_APP) @@ -355,6 +380,9 @@ object ConstraintUtils { 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/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/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/sorting/comparators/KeyMapConstraintsComparator.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt index 047e460fe3..5e6b58f269 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 @@ -95,6 +95,15 @@ class KeyMapConstraintsComparator( is ConstraintData.ImeChosen -> Success(constraint.data.imeLabel) is ConstraintData.InPhoneCall -> Success("") is ConstraintData.MediaPlaying -> 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("") 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 536a4390c8..f494fb6351 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 @@ -49,6 +49,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { 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 @@ -126,6 +127,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 diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 1a00f447af..aaf2cefb4c 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -348,6 +348,39 @@ 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 @@ -1410,6 +1443,7 @@ Phone Power Device + Notifications Time 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/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/utils/TestConstraintSnapshot.kt b/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt index 3cbbfd9de2..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,20 +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.AppPlayingMedia -> appsPlayingMedia.contains(data.packageName) is ConstraintData.MediaPlaying -> appsPlayingMedia.isNotEmpty() + + is ConstraintData.NotificationPosted -> if (constraint.isNot) { + postedNotifications.all { + NotificationConstraintMatcher.matches(it, constraint.data) + } + } else { + postedNotifications.any { + NotificationConstraintMatcher.matches(it, constraint.data) + } + } + 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 @@ -64,6 +80,7 @@ class TestConstraintSnapshot( physicalOrientation == data.physicalOrientation is ConstraintData.ScreenOn -> isScreenOn + is ConstraintData.DisplayResolution -> (displaySize.width == data.width && displaySize.height == data.height) || (displaySize.width == data.height && displaySize.height == data.width) @@ -83,15 +100,25 @@ class TestConstraintSnapshot( } is ConstraintData.WifiOn -> isWifiEnabled + is ConstraintData.ImeChosen -> chosenImeId == data.imeId + is ConstraintData.KeyboardShowing -> isKeyboardShowing + is ConstraintData.DeviceIsLocked -> 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.LockScreenShowing -> isLockscreenShowing + is ConstraintData.Time -> { val startTime = data.startTime val endTime = data.endTime @@ -105,8 +132,10 @@ class TestConstraintSnapshot( ConstraintData.HingeClosed -> hingeState is HingeState.Available && hingeState.isClosed() + ConstraintData.HingeOpen -> hingeState is HingeState.Available && hingeState.isOpen() + ConstraintData.NotificationPanelShowing -> isNotificationPanelShowing } != constraint.isNot 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 d2eb275787..984352b37d 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 @@ -143,6 +143,8 @@ data class ConstraintEntity( 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" @@ -153,6 +155,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" diff --git a/system/src/main/java/io/github/sds100/keymapper/system/notifications/AndroidNotificationAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/AndroidNotificationAdapter.kt index e1d73b0316..f54340ba0f 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/notifications/AndroidNotificationAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/AndroidNotificationAdapter.kt @@ -20,6 +20,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import io.github.sds100.keymapper.common.KeyMapperClassProvider import io.github.sds100.keymapper.common.notifications.KMNotificationAction 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 @@ -47,9 +48,6 @@ class AndroidNotificationAdapter @Inject constructor( override val activeNotifications: StateFlow> = postedNotificationStore.active - override val recentNotifications: StateFlow> = - postedNotificationStore.recent - private val broadcastReceiver: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { context ?: return @@ -97,7 +95,7 @@ class AndroidNotificationAdapter @Inject constructor( setContentText(notification.text) if (notification.onClickAction != null) { - val pendingIntent = createActionIntent(notification.onClickAction!!) + val pendingIntent = createActionIntent(notification.onClickAction) setContentIntent(pendingIntent) } @@ -113,7 +111,7 @@ class AndroidNotificationAdapter @Inject constructor( } if (notification.timeout != null) { - this.setTimeoutAfter(notification.timeout!!) + this.setTimeoutAfter(notification.timeout) } if (notification.showIndeterminateProgress) { @@ -283,16 +281,19 @@ class AndroidNotificationAdapter @Inject constructor( override suspend fun dismissAllNotifications(): KMResult<*> = notificationReceiverAdapter.send(NotificationServiceEvent.DismissAllNotifications) - override suspend fun dismissLastNotification(): KMResult<*> = - notificationReceiverAdapter.send(NotificationServiceEvent.DismissLastNotification) + 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 onNotificationsSeeded(notifications: List) { - postedNotificationStore.onSeeded(notifications) + fun seedNotifications(notifications: List) { + postedNotificationStore.seed(notifications) } /** 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 22de986b04..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 @@ -3,6 +3,7 @@ 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 { /** @@ -15,6 +16,12 @@ 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) @@ -27,7 +34,7 @@ interface NotificationAdapter { suspend fun dismissAllNotifications(): KMResult<*> /** - * Dismiss the notification that was posted most recently. + * 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() } + } +} From e620f6ee021621deef99bba3ecdbadffd756d162 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 19 Sep 2026 10:49:06 +0200 Subject: [PATCH 052/101] use delete icon in trigger key list item --- .../sds100/keymapper/base/trigger/TriggerKeyListItem.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 c3e941e29b..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,7 +22,7 @@ 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.CardDefaults import androidx.compose.material3.ElevatedCard @@ -199,6 +199,8 @@ fun TriggerKeyListItem( CompactErrorButton(onClick = { onFixClick(model.error!!) }) { Text(text = stringResource(R.string.button_fix)) } + + Spacer(Modifier.width(4.dp)) } CompositionLocalProvider( @@ -219,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), ) } From 40c7d9f42a609ff87ed1df2e0b774b53ad181109 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 19 Sep 2026 10:49:47 +0200 Subject: [PATCH 053/101] grey out the action delay button when it is disabled --- .../keymapper/base/actions/ActionsScreen.kt | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) 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 5182021ab5..110f29c78c 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 @@ -375,6 +375,7 @@ private fun ActionList( if (model.showDelayChip) { ActionLinkRow( + isEnabled = model.isEnabled, delayBeforeNextAction = model.delayBeforeNextAction, onDelayClick = { onDelayClick(model.id) }, ) @@ -420,9 +421,23 @@ 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() @@ -475,10 +490,7 @@ private fun ActionLinkRow( contentDescription = null, ) }, - colors = AssistChipDefaults.assistChipColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - leadingIconContentColor = MaterialTheme.colorScheme.onSurface, - ), + colors = colors, border = null, ) } @@ -603,12 +615,12 @@ private fun LoadedPreview() { isCustomName = true, showDelayChip = true, delayBeforeNextAction = 100, + isEnabled = false, ), ActionListItemModel( id = "3", icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn), title = "Toggle Back flashlight", - isEnabled = false, ), ), shortcuts = setOf( From 13c1408a246dae1499852166dbb29bb0f3739a4b Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 19 Sep 2026 11:01:00 +0200 Subject: [PATCH 054/101] fix: allow clearing action and constraint group names --- .../keymapper/base/actions/ActionsScreen.kt | 2 ++ .../base/constraints/ConstraintsScreen.kt | 1 + .../base/utils/ui/compose/ComposeDialogs.kt | 28 +++++++++++++------ 3 files changed, 22 insertions(+), 9 deletions(-) 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 110f29c78c..0d5cdb1e89 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 @@ -174,6 +174,8 @@ private fun ActionsScreen( 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 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 926158d9cc..f4b68c8478 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 @@ -173,6 +173,7 @@ private fun ConstraintsScreen( 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 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 b0816ed60e..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 @@ -47,6 +47,8 @@ fun TextFieldDialog( title: String, submitButtonText: String, initialText: String, + hint: String? = null, + canBeEmpty: Boolean = false, /** * Returns an error message. */ @@ -64,8 +66,12 @@ fun TextFieldDialog( ), ) } + var error: String? by remember { mutableStateOf(null) } - val isError by remember { derivedStateOf { textFieldValue.text.isBlank() || error != null } } + + val isError by remember { derivedStateOf { error != null } } + + val emptyErrorText = stringResource(R.string.error_cant_be_empty) LaunchedEffect(Unit) { focusRequester.requestFocus() @@ -109,6 +115,15 @@ fun TextFieldDialog( onValueChange = { error = null textFieldValue = it + + if (!canBeEmpty && textFieldValue.text.isBlank()) { + error = emptyErrorText + } + }, + placeholder = if (hint == null) { + null + } else { + { Text(hint) } }, singleLine = true, maxLines = 1, @@ -122,13 +137,7 @@ fun TextFieldDialog( }, ), supportingText = { - when { - error != null -> Text(error!!) - - textFieldValue.text.isBlank() -> { - Text(stringResource(R.string.error_cant_be_empty)) - } - } + if (error != null) Text(error!!) }, ) } @@ -230,12 +239,13 @@ private fun TextFieldDialogPreview() { @Preview(widthDp = 800, heightDp = 400) @Composable -private fun TextFieldDialogEmptyPreview() { +private fun TextFieldDialogHintPreview() { KeyMapperTheme { TextFieldDialog( title = "Title", submitButtonText = "Submit", initialText = "", + hint = "Some placeholder text", ) } } From e4447c278a9fc366743413610487217694a6f613 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 19 Sep 2026 11:04:49 +0200 Subject: [PATCH 055/101] fix: grey out drawable icons in disabled groups --- .../sds100/keymapper/base/groups/GroupRow.kt | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) 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 62ecbc641b..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 @@ -143,7 +144,8 @@ fun GroupRow( 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, @@ -368,6 +370,27 @@ private fun PreviewDisabledGroup() { } } +@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() { From 9f7d1258616e4b1d5f629ff50000a468fea67b32 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 19 Sep 2026 11:07:51 +0200 Subject: [PATCH 056/101] fix: grey out drawable icons in key map list disabled actions and constraints --- .../io/github/sds100/keymapper/base/home/KeyMapListScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 a46e7ef45c..d6e0de0ec3 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 @@ -41,6 +41,7 @@ import androidx.compose.runtime.remember 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.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -484,6 +485,7 @@ private fun ActionConstraintChip(model: ComposeChipModel, onFixClick: (KMError) when (icon) { is ComposeIconInfo.Drawable -> Icon( modifier = Modifier + .alpha(contentAlpha) .fillMaxHeight(), painter = rememberDrawablePainter(icon.drawable), contentDescription = null, @@ -495,7 +497,6 @@ private fun ActionConstraintChip(model: ComposeChipModel, onFixClick: (KMError) .fillMaxHeight(), imageVector = icon.imageVector, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, ) } } @@ -576,6 +577,7 @@ private fun sampleList(): List { drawable = context.drawable(R.drawable.ic_launcher_web), ), "Open Key Mapper", + isEnabled = false, ), ComposeChipModel.Error( id = "1", @@ -591,6 +593,7 @@ private fun sampleList(): List { id = "3", text = "Toggle flashlight", icon = ComposeIconInfo.Vector(Icons.Outlined.FlashlightOn), + isEnabled = false, ), ), constraintMode = ConstraintMode.AND, From 25615baf3260d3b7997244c13c0fe7bdce2940b8 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 19 Sep 2026 13:11:36 +0200 Subject: [PATCH 057/101] fix: do not animate a DraggableItem every time the list is drawn --- .../keymapper/base/utils/ui/compose/DraggableItem.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 7d05a62527..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 @@ -19,6 +19,7 @@ fun LazyItemScope.DraggableItem( content: @Composable BoxScope.(isDragging: Boolean) -> Unit, ) { val dragging = key == dragDropState.draggingItemKey + val draggingModifier = if (dragging) { Modifier .zIndex(1f) @@ -31,11 +32,18 @@ fun LazyItemScope.DraggableItem( .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) From 8e9502b8490fc22ea1b425029a8cecd162be549c Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 19 Sep 2026 13:14:48 +0200 Subject: [PATCH 058/101] fix: handle action list item expanded state in view model --- .../base/actions/ActionListItemModel.kt | 1 + .../keymapper/base/actions/ActionsScreen.kt | 21 +------ .../base/actions/ConfigActionsUseCase.kt | 50 +++++++++------- .../base/actions/ConfigActionsViewModel.kt | 60 +++++++++++++++++-- .../base/keymaps/ConfigKeyMapState.kt | 7 ++- 5 files changed, 90 insertions(+), 49 deletions(-) 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 index cff9fbb8b0..633b9c249c 100644 --- 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 @@ -6,6 +6,7 @@ data class ActionListItemModel( val id: String, val icon: ComposeIconInfo, val title: String, + val isExpanded: Boolean = false, val isCustomName: Boolean = false, val isEnabled: Boolean = true, /** 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 0d5cdb1e89..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 @@ -33,7 +33,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -246,6 +245,7 @@ interface ActionListCallback { fun onActionTipDismiss() = run { } fun onTipButtonClick(id: String) = run { } fun onEnabledChange(id: String, enabled: Boolean) = run { } + fun onExpandedChange(id: String, expanded: Boolean) = run {} } @Composable @@ -260,9 +260,6 @@ private fun ActionList( ) { val lazyListState = rememberLazyListState() - // A list rather than a set so it can be saved in a Bundle. - var expandedIds by rememberSaveable { mutableStateOf(listOf()) } - val actions = (state as? ConfigActionsState.Loaded)?.actions.orEmpty() val actionIds = remember(actions) { actions.map { it.id } } @@ -324,12 +321,6 @@ private fun ActionList( key = { _, item -> item.id }, contentType = { _, _ -> "action" }, ) { index, model -> - // Automatically expand the item when an error appears so the user can fix it. - LaunchedEffect(model.id, model.error) { - if (model.isEnabled && model.error != null && model.id !in expandedIds) { - expandedIds = expandedIds + model.id - } - } DraggableItem( dragDropState = dragDropState, @@ -342,18 +333,12 @@ private fun ActionList( .padding(horizontal = 16.dp), model = model, index = index, - isExpanded = model.id in expandedIds, + isExpanded = model.isExpanded, isDraggingEnabled = orderedActions.size > 1, isDragging = isDragging, isReorderingEnabled = state.isReorderingEnabled, dragDropState = dragDropState, - onExpandedChange = { expanded -> - expandedIds = if (expanded) { - expandedIds + model.id - } else { - expandedIds - model.id - } - }, + onExpandedChange = { callback.onExpandedChange(model.id, it) }, onEditClick = { callback.onEditClick(model.id) }, onRemoveClick = { onRemoveClick(model.id) }, onFixClick = { callback.onFixErrorClick(model.id) }, 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 2623ab8a53..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,6 +8,7 @@ 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 @@ -41,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) { @@ -292,7 +295,10 @@ 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) 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 c754c261ed..474c9bc941 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 @@ -78,6 +78,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 +92,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,9 +142,13 @@ class ConfigActionsViewModel @Inject constructor( return config.keyMap.first().dataOrNull()?.actionList?.singleOrNull { it.uid == uid }?.data } - override 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) } + } } } @@ -160,7 +193,11 @@ 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) @@ -293,6 +330,14 @@ class ConfigActionsViewModel @Inject constructor( 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 @@ -326,13 +371,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, @@ -345,6 +391,7 @@ class ConfigActionsViewModel @Inject constructor( keyMap: KeyMap, showDeviceDescriptors: Boolean, errorSnapshot: ActionErrorSnapshot, + expandedActions: Set, ): List { val actionErrors = errorSnapshot.getErrors(keyMap.actionList.map { it.data }) @@ -368,6 +415,7 @@ class ConfigActionsViewModel @Inject constructor( 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, 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) From 09d1e2bedadcbb569f367fc5541b30ace47d6774 Mon Sep 17 00:00:00 2001 From: Seth Schroeder Date: Sat, 19 Sep 2026 13:22:17 +0200 Subject: [PATCH 059/101] Update crowdin.yaml --- crowdin.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 69eebdde97ca29ad1e2e139a19956727e3960c1e Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sat, 19 Sep 2026 11:22:55 +0000 Subject: [PATCH 060/101] New Crowdin translations by GitHub Action --- base/src/main/res/values-zh-rCN/strings.xml | 279 +++++++++++++++++--- 1 file changed, 249 insertions(+), 30 deletions(-) diff --git a/base/src/main/res/values-zh-rCN/strings.xml b/base/src/main/res/values-zh-rCN/strings.xml index e4f0911123..e3932a8eff 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 @@ 亮色 暗色 系统 - 语言 - 选择应用语言 - 跟随系统 显示音量对话框 @@ -608,6 +645,7 @@ 包名为 %s 的应用未安装! 应用 %s 已禁用! 您需要授予 Key Mapper 修改系统设置的权限。 + Key Mapper 需要“所有文件访问”权限。 这需要 root 权限! 此操作需要相机权限! 需要 Android %s 或更新版本 @@ -639,6 +677,7 @@ 无前置闪光灯 无后置闪光灯 手电筒不支持可变亮度 + 您的设备暂不支持黑夜模式 无障碍服务需要启用! 无障碍需要重启! 您的启动器不支持快捷方式。 @@ -649,6 +688,10 @@ 全局动作 %s 执行失败! 此操作需要配置 找不到电池优化设置,如果该设置项存在,请手动将其打开 + 在列表中找到 Key Mapper 并为它禁用电池优化 + 转到特殊应用权限 -> 电池优化 -> 禁用 Key Mapper + 转到设置 -> 特殊应用权限 -> 电池优化 -> 禁用 Key Mapper + 在这个设备上的设置页面未找到“所有文件访问”。 找不到额外 (%s) ! 不能有重复的条件! 不能为空! @@ -656,6 +699,7 @@ 空的 SON 文件! 文件访问被拒绝!%s 未知 IO 错误! + 存储不可用。请稍后再试。 已取消 无效的数字! 必须至少为 %s! @@ -687,6 +731,8 @@ 需要打开%s! 切换输入法失败! 启用输入法失败! + 循环键盘语言失败! + 当前键盘没有启用多语言! 此设备无相机应用! 此设备无助理! 您的设备没有设置! @@ -709,6 +755,7 @@ 拒绝了查看配对蓝牙设备的权限! 拒绝了显示通知的权限! 读取日志权限已被拒绝! + 已拒绝访问本地网络的权限! 必须是 2 或以上! 必须是 %d 或以下! 必须大于 0! @@ -803,11 +850,19 @@ 停止一个应用的媒体 停止 %s 的媒体 步进媒体 + 媒体前进 (%ds) 步进一个应用的媒体 步进 %s 的媒体 + 媒体前进 %1$s (%2$ds) 步退媒体 + 媒体后退 (%ds) 步进一个应用的媒体 步进 %s 的媒体 + 媒体后退 %1$s (%2$ds) + 自定义进退数量 + 进退 + %d 秒 + 不是所有应用支持自定义进退数量。某些应用会使用它们的固定跳过时长。 返回 主屏幕 打开最近任务 @@ -861,12 +916,15 @@ 显示键盘 隐藏键盘 显示键盘选择器 + 键盘输入/发送 切换键盘 切换到 %s + 循环键盘语言 剪切 复制 粘贴 在光标处选择单词 + 选择所有文本 打开设置 显示电源菜单 切换飞行模式 @@ -901,7 +959,7 @@ 播放声音 忽略多数最近的通知 清除所有通知 - 创建通知 + 显示通知 显示通知:%1$s 通知标题 请输入通知标题 @@ -915,6 +973,14 @@ 测试 正在测试按键中…… 通知已成功显示 + 显示屏幕消息 + 显示短的消息:%1$s + 显示长的消息:%1$s + 吐司消息 + 输入吐司消息 + 消息不能为空 + 短整形 + 长整型 设备控制屏幕 HTTP 请求 HTTP 方法 @@ -989,6 +1055,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 +1124,7 @@ 注意:仅修改设置值可能不足以使更改生效,某些设置需要额外的操作或广播才能被系统处理 测试 设置修改成功 - 授权 + 授予权限 选择设置项 没有找到设置 选择已有设置项 @@ -1083,6 +1211,8 @@ 此应用的开源许可 隐私政策 正如隐私政策所说我们不会收集任何私人信息 + 客户 ID + 已复制客户 ID 我们的团队 开发人员 用户体验设计师 @@ -1091,21 +1221,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 +1298,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 +1363,9 @@ 铃声模式动作 建议在执行铃声模式动作时使用专家模式,以避免与“请勿打扰”设置冲突 使用专家模式 + 改用专用操作 + 这个不会如你所想那样工作。改用专用动作\"%1$s\"。 + 覆盖操作 仅限特定应用使用? 在“约束”选项卡中添加约束条件 选择一个布局 @@ -1209,6 +1385,7 @@ 动作 排除 选项 + 已启用 按键映射 悬浮按钮 新建按键映射 @@ -1218,7 +1395,7 @@ 没有足够的按钮? 您自己来制作! 悬浮按钮可以显示在您需要的应用程序之上。它们就像实体按钮一样工作,并且您可以随心所欲地放置、美化、映射它们。 - 隐藏此页面 + 隐藏 感谢您对 Key\u00A0Mapper\u00A0❤️ 的支持! 创建您的第一个布局! 布局功能可助您将悬浮按钮分组管理,但不会影响按钮的显示状态。任何布局中的按钮均可同时显示在屏幕上。 @@ -1237,12 +1414,13 @@ 是的,删除 取消 隐藏悬浮布局 - 在创建触发器时,您可以在“商店”按钮中找到悬浮按钮 + 在创建一个触发时你可以在商店按钮中找到浮动按钮。 悬浮按钮 菜单 排序 更多 帮助 + 最新更新 全选 取消全选 停止选择 @@ -1255,6 +1433,7 @@ 设置 删除群组 关于 + 报告问题 导出全部 导入 选择键盘 @@ -1262,6 +1441,10 @@ 导入成功! 导出中… 导出失败: %s + 已保存 %s 到 Downloads + 选择一个备份 + 在 Downloads 中未找到之前的 Key Mapper 备份。先在这个设备导出一个备份,然后它会在这里显示。 + 授权来查看其他应用的文件 加载文件… 导入成功! 导入中… @@ -1344,6 +1527,7 @@ 是的,删除 取消 触发按键映射时运行这些操作: + 重复间隔小于 20 毫秒可能会卡顿!如果卡顿请调整时间。 选择侧边 亮度 最小 @@ -1390,7 +1574,7 @@ 使用专家模式(Expert Mode)重新映射按键具有一定的危险性,如果映射不当,可能会导致按键失效。\n\n如果您操作失误,可能需要强制重启设备,方法通常是长按电源键和音量键 30 秒——请查阅您的设备手册或通过网络搜索了解具体操作方法 %d…… 我已知悉 - 已了解 + 明白了 设置 已检测到 Root 权限 通过授予键映射器Root 权限,您可以跳过设置过程,这还能让键映射器自动开启专家模式,无需等待 WiFi 连接 @@ -1413,11 +1597,15 @@ 获取命令 重试 获取命令失败,请重试 + 开启专家模式失败 + 调试桥 adb 无法启动。请重试,如果问题仍然存在,请联系开发者 contact@keymapper.app。 系统桥接启动命令 将命令复制到剪贴板 自启动与保持后台运行 每当您重启设备或应用意外退出时,专家模式都会自动启动 无论是在设备开机还是程序意外退出时,“专家模式”都会自动启动,不过这需要获取 WRITE_SECURE_SETTINGS 权限 + 重新映射了电源按钮紧急停止 + 如果重新映射了电源按钮,长按电源按钮 10 秒并释放它来停止专家模式。如果电源按钮故障并且意外停止专家模式,关闭它。 按键事件动作 选择按键事件动作的执行方式 紧急提示 @@ -1446,17 +1634,42 @@ 允许通知 Key Mapper 需要获取通知权限,以便在设置过程中出现问题时及时提醒您 授予权限 + 允许访问本地网络 + Key Mapper 需要访问本地网络来连接在你的设备上的 ADB 服务。 + 授予权限 USB 配置不兼容 您必须将默认 USB 配置设置为“不进行数据传输”,以防止“专家模式”在每次锁屏时被强行关闭 “USB 调试(安全设置)”已禁用 您需要在“开发者选项”中开启“USB 调试(安全设置)”,专家模式才能正常工作,这通常是小米等设备的必要操作 打开开发者选项 + 减少杀死应用 + 小米设备 (MIUI/HyperOS) 积极关闭后台应用。应用少量设置来保持专家模式运行。 + 减少杀死应用 + 小米设备 (MIUI/HyperOS) 积极关闭后台应用并撤销权限。这些设置帮助保持专家模式运行。系统更新后你可能需要重新应用它们。 + 启动专家模式来更改这个设置。 + 出错了。确保专家模式正在运行并再试一次。 + 在这个设备不能打开设置屏幕。 + 已应用 + 电池修复 + 从电池优化白名单本应用并允许它在后台运行。应用这些是安全的。 + 应用电池修复 + MIUI 优化 + 禁用 MIUI 优化是最有效的修复,但它可以更改某些 MIUI 功能的行为。 + 禁用 MIUI 优化 + 你可能需要重启你的设备来使它完全生效。再次关闭它来逆转。 + 打开 + 启用自动启动 + 允许本应用自动启动,这样在设备重启之后它可以运行。 + 省电模式 + 设置本应用的省电模式为“无限制”,这样它在后台不会被关闭。 + 在最近应用中锁定本应用 + 打开最近应用屏幕,然后在本应用的卡片向下滑动(或者长按它)并点击锁定。清除所有时不会关闭它。 设置专家模式助手 专家模式正在运行 您现在可以在熄屏状态下重映射按键,并使用更多功能 完成专家模式配置 开启开发者选项 - 连续点击手机系统设置中的版本号五次以开启开发者模式 + 重复点击设备版本号以打开开发者模式 自动配对 正在搜索配对码和端口…… 未找到搜索配对码和端口 @@ -1477,7 +1690,7 @@ 专家模式有什么用? 📲 您可以映射更多按键,例如电源键。\n⌨️ 在使用“按键代码 (Key Code)”动作时,可配合任何屏幕键盘使用。 \n⭐️ 解锁并使用更多高级动作。 显示专家模式信息 - 忽略 + 关闭 专家模式意外停止 正在自动重启…… 由于距离上次自动启动不到 5 分钟,将不再自动重启,如果您没有手动强杀服务,请向开发者报告此问题 @@ -1502,8 +1715,8 @@ 锁屏 提升您的使用体验 悬浮按钮可在任何应用、游戏或锁定屏幕上作为即时快捷方式使用,您的支持是键映射器持续开发的动力 ❤️ - 查看演示 - 忽略 + 看它怎样工作 + 关闭 只需 %s 即可解锁 @@ -1578,6 +1791,12 @@ 你喜欢悬浮按钮功能吗? 我们很想知道您的想法!请考虑在 Google Play 上留下评价,以帮助其他人发现此功能。❤️ - 忽略 + 关闭 写入 evdev 事件失败 + 报告问题 + 我们希望你把错误报告到 Discord 服务器,使其他用户可以帮助你。否则,你可以用邮件联系我们 contact@keymapper.app。 + 打开 Discord + 给我们发送电子邮件 + 禁用自动切换键盘? + 在这里选择一个键盘会禁用自动切换键盘功能。你可以在设置里重新启用它。 From 11816dc30d67cb55875ae614fe51810862db8d52 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 19 Sep 2026 13:26:22 +0200 Subject: [PATCH 061/101] tell Claude to run ktlint format before check --- CLAUDE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f902d959ab..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 From 19baeb984d690bfb38e888f727e6c64835a26891 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 12:19:46 +0200 Subject: [PATCH 062/101] fix: constraint groups remember whether they are expanded and auto expand a new group when a constraint is added --- .../constraints/ConfigConstraintsUseCase.kt | 55 ++++--- .../constraints/ConfigConstraintsViewModel.kt | 58 +++++++- .../base/constraints/ConstraintGroupItem.kt | 134 +++++++----------- .../constraints/ConstraintListItemModel.kt | 1 + .../base/constraints/ConstraintsScreen.kt | 36 +---- .../utils/ui/compose/ColumnDragDropState.kt | 123 ++++++++++++++++ 6 files changed, 271 insertions(+), 136 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ColumnDragDropState.kt 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 7aae9de4db..6cc67f5f89 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 @@ -41,11 +42,15 @@ class ConfigConstraintsUseCaseImpl @Inject constructor( .take(5) } - override fun addConstraint(groupUid: String?, 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 -> + val newKeyMap = updateConstraintState { oldState -> val group = oldState.groups.find { it.uid == groupUid } if (group == null) { @@ -60,6 +65,8 @@ class ConfigConstraintsUseCaseImpl @Inject constructor( oldState.mode } + affectedGroup = newGroup + return@updateConstraintState oldState.copy( groups = oldState.groups.plus(newGroup), mode = mode, @@ -72,26 +79,33 @@ class ConfigConstraintsUseCaseImpl @Inject constructor( if (containsConstraint) { oldState } else { - oldState.updateGroup(group.uid) { - it.copy(constraints = it.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 + } - val newDataList = LinkedList(oldDataList) - .apply { addFirst(constraintData) } - .distinct() + preferenceRepository.update(Keys.recentlyUsedConstraints) { old -> + val oldDataList = getConstraintShortcuts(old) - Json.encodeToString(newDataList) - }, - ) + val newDataList = LinkedList(oldDataList) + .apply { addFirst(constraintData) } + .distinct() - return !containsConstraint + Json.encodeToString(newDataList) + } + + return if (containsConstraint) { + null + } else { + affectedGroup + } } override fun removeConstraint(uid: String) { @@ -162,10 +176,10 @@ class ConfigConstraintsUseCaseImpl @Inject constructor( } } - private fun updateConstraintState(block: (ConstraintState) -> ConstraintState) { - state.update { keyMap -> + private fun updateConstraintState(block: (ConstraintState) -> ConstraintState): KeyMap? { + return state.update { keyMap -> keyMap.copy(constraintState = block(keyMap.constraintState)) - } + }.dataOrNull() } private fun ConstraintState.updateGroup( @@ -221,9 +235,10 @@ interface ConfigConstraintsUseCase { /** * @param groupUid the group to add the constraint to. A new group is created if this is null. - * @return false if the group already contains the constraint. + * @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): Boolean + fun addConstraint(groupUid: String?, constraintData: ConstraintData): ConstraintGroup? fun removeConstraint(uid: String) fun removeGroup(groupUid: String) fun toggleNot(constraintUid: String) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsViewModel.kt index a0b0cc4e6e..41d158251b 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 @@ -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,21 +71,54 @@ 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(groupUid = null, constraintData) + val group = config.addConstraint(groupUid = null, constraintData) + + if (group != null) { + expandedGroups.update { set -> set.plus(group.uid) } + } } } @@ -151,18 +185,28 @@ class ConfigConstraintsViewModel @Inject constructor( */ fun addConstraint(groupUid: String?) { viewModelScope.launch { - val constraint: ConstraintData = + val constraintData: ConstraintData = navigate("add_constraint", NavDestination.ChooseConstraint) ?: return@launch - val isDuplicate = !config.addConstraint(groupUid, 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 { @@ -178,6 +222,7 @@ class ConfigConstraintsViewModel @Inject constructor( state: ConstraintState, shortcuts: Set>, errorSnapshot: ConstraintErrorSnapshot, + expandedGroups: Set, ): ConfigConstraintsState { if (state.constraints.isEmpty()) { return ConfigConstraintsState.Empty(shortcuts) @@ -212,6 +257,7 @@ class ConfigConstraintsViewModel @Inject constructor( description = group.constraints.joinToString(separator = " $modeWord ") { uiHelper.getTitle(it) }, + isExpanded = expandedGroups.contains(group.uid), ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt index 1b7350db6c..44faa40432 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt @@ -40,15 +40,10 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateMapOf -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.graphicsLayer -import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.LookaheadScope import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -56,14 +51,15 @@ 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 androidx.compose.ui.zIndex 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.DraggableColumnItem 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.compose.rememberColumnDragDropState /** * The description and error fade out in the first half of the animation so they are invisible @@ -360,88 +356,66 @@ private fun GroupConstraintList( onNotClick: (String) -> Unit, onMoveConstraint: (fromIndex: Int, toIndex: Int) -> Unit, ) { - var draggingIndex by remember { mutableStateOf(null) } - var dragOffset by remember { mutableFloatStateOf(0f) } - val itemHeights = remember { mutableStateMapOf() } + val dragDropState = rememberColumnDragDropState( + itemCount = constraints.size, + onMove = onMoveConstraint, + ) 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) { - val isDragging = draggingIndex == index - - val draggableState = rememberDraggableState { delta -> - val currentIndex = draggingIndex ?: return@rememberDraggableState - val itemHeight = constraints.getOrNull(currentIndex) - ?.let { itemHeights[it.id] } - ?: return@rememberDraggableState - - dragOffset += delta - - if (dragOffset > itemHeight / 2f && currentIndex < constraints.lastIndex) { - onMoveConstraint(currentIndex, currentIndex + 1) - draggingIndex = currentIndex + 1 - dragOffset -= itemHeight - } else if (dragOffset < -itemHeight / 2f && currentIndex > 0) { - onMoveConstraint(currentIndex, currentIndex - 1) - draggingIndex = currentIndex - 1 - dragOffset += itemHeight - } - } - - Column( - modifier = Modifier - .onSizeChanged { itemHeights[constraint.id] = it.height } - .zIndex(if (isDragging) 1f else 0f) - .graphicsLayer { translationY = if (isDragging) dragOffset else 0f }, - ) { - ConstraintListItem( - modifier = Modifier.fillMaxWidth(), - model = constraint, - isReorderingEnabled = constraints.size > 1, - isDragging = isDragging, - dragHandleModifier = Modifier.draggable( - state = draggableState, - orientation = Orientation.Vertical, - startDragImmediately = true, - onDragStarted = { - draggingIndex = index - dragOffset = 0f - }, - onDragStopped = { - draggingIndex = null - dragOffset = 0f - }, - ), - onRemoveClick = { onRemoveConstraintClick(constraint.id) }, - onFixClick = { onFixConstraintClick(constraint.id) }, - onNotClick = { onNotClick(constraint.id) }, - onMoveUp = if (index > 0) { - { onMoveConstraint(index, index - 1) } - } else { - null - }, - onMoveDown = if (index < constraints.lastIndex) { - { onMoveConstraint(index, index + 1) } - } else { - null - }, - ) - - if (index < constraints.lastIndex) { - Text( + LookaheadScope { + Column(modifier = modifier) { + constraints.forEachIndexed { index, constraint -> + key(constraint.id) { + val draggableState = rememberDraggableState(dragDropState::onDrag) + + DraggableColumnItem( + dragDropState = dragDropState, + index = index, + ) { isDragging, heightModifier -> + ConstraintListItem( modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), - text = linkText, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, + .then(heightModifier), + model = constraint, + isReorderingEnabled = constraints.size > 1, + isDragging = isDragging, + dragHandleModifier = Modifier.draggable( + state = draggableState, + orientation = Orientation.Vertical, + startDragImmediately = true, + onDragStarted = { dragDropState.onDragStarted(index) }, + onDragStopped = { dragDropState.onDragStopped() }, + ), + onRemoveClick = { onRemoveConstraintClick(constraint.id) }, + onFixClick = { onFixConstraintClick(constraint.id) }, + onNotClick = { onNotClick(constraint.id) }, + onMoveUp = if (index > 0) { + { onMoveConstraint(index, index - 1) } + } else { + null + }, + onMoveDown = if (index < constraints.lastIndex) { + { onMoveConstraint(index, index + 1) } + } else { + null + }, ) + + 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, + ) + } } } } 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 0159dcf6f5..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 @@ -17,6 +17,7 @@ data class ConstraintGroupListItemModel( 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/ConstraintsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintsScreen.kt index f4b68c8478..b3a6bf40bb 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 @@ -87,6 +87,7 @@ fun ConstraintsScreen( onSelectMode = viewModel::onSelectMode, onSelectGroupMode = viewModel::onSelectGroupMode, onRenameGroup = viewModel::onRenameGroup, + onExpandedChange = viewModel::onExpandedChange, onMoveGroup = viewModel::onMoveGroup, onMoveConstraint = viewModel::onMoveConstraint, ) @@ -106,6 +107,7 @@ private fun ConstraintsScreen( 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 = { _, _ -> }, onMoveConstraint: (groupUid: String, fromIndex: Int, toIndex: Int) -> Unit = { _, _, _ -> }, ) { @@ -235,6 +237,7 @@ private fun ConstraintsScreen( onClickShortcut = onClickShortcut, onSelectGroupMode = onSelectGroupMode, onRenameGroupClick = { groupToRename = it }, + onExpandedChange = onExpandedChange, onMoveGroup = onMoveGroup, onMoveConstraint = onMoveConstraint, ) @@ -298,27 +301,13 @@ private fun ConstraintGroupList( onClickShortcut: (ConstraintData) -> Unit, onSelectGroupMode: (String, ConstraintMode) -> Unit, onRenameGroupClick: (groupUid: String) -> Unit, + onExpandedChange: (groupUid: String, expanded: Boolean) -> Unit, onMoveGroup: (fromIndex: Int, toIndex: Int) -> Unit, onMoveConstraint: (groupUid: String, fromIndex: Int, toIndex: Int) -> Unit, ) { val lazyListState = rememberLazyListState() val groupUids = state.groups.map { it.uid } - // Lists rather than sets so they can be saved in a Bundle. - var expandedUids by rememberSaveable { mutableStateOf(emptyList()) } - var knownGroupUids by rememberSaveable { mutableStateOf(groupUids) } - - // Expand groups that are added so the user can see the new constraint. - LaunchedEffect(groupUids) { - val newGroupUids = groupUids.filterNot { it in knownGroupUids } - - if (newGroupUids.isNotEmpty()) { - expandedUids = expandedUids + newGroupUids - } - - knownGroupUids = groupUids - } - // Only the groups can be dragged. Not the row of shortcuts. val dragDropState = rememberDragDropState( lazyListState = lazyListState, @@ -354,13 +343,6 @@ private fun ConstraintGroupList( key = { _, group -> group.uid }, contentType = { _, _ -> "constraint_group" }, ) { index, group -> - // Automatically expand the group when an error appears so the user can fix it. - LaunchedEffect(group.uid, group.error) { - if (group.error != null && group.uid !in expandedUids) { - expandedUids = expandedUids + group.uid - } - } - DraggableItem( dragDropState = dragDropState, key = group.uid, @@ -371,18 +353,12 @@ private fun ConstraintGroupList( .fillMaxWidth() .padding(horizontal = 16.dp), model = group, - isExpanded = group.uid in expandedUids, + isExpanded = group.isExpanded, isDraggingEnabled = orderedGroups.size > 1, isDragging = isDragging, isReorderingEnabled = state.groups.size > 1, dragDropState = dragDropState, - onExpandedChange = { expanded -> - expandedUids = if (expanded) { - expandedUids + group.uid - } else { - expandedUids - group.uid - } - }, + onExpandedChange = { expanded -> onExpandedChange(group.uid, expanded) }, onSelectMode = { onSelectGroupMode(group.uid, it) }, onAddConstraintClick = { onAddToGroupClick(group.uid) }, onRenameClick = { onRenameGroupClick(group.uid) }, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ColumnDragDropState.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ColumnDragDropState.kt new file mode 100644 index 0000000000..2e06c4c6f8 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ColumnDragDropState.kt @@ -0,0 +1,123 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.animation.animateBounds +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.zIndex + +/** + * Drag-and-drop state for reordering items in a plain [Column] rather than a lazy list. Unlike + * [DragDropState], which compares on-screen bounds, items are swapped one at a time as soon as + * the dragged item has moved over half of its own height. + */ +@Stable +class ColumnDragDropState internal constructor( + private val itemCount: () -> Int, + private val onMove: (fromIndex: Int, toIndex: Int) -> Unit, +) { + var draggingIndex by mutableStateOf(null) + private set + + var dragOffset by mutableFloatStateOf(0f) + private set + + /** + * The last measured height of whichever item is currently laid out at each index. + */ + private val itemHeights = mutableStateMapOf() + + internal fun onSizeChanged(index: Int, height: Int) { + itemHeights[index] = height + } + + fun onDragStarted(index: Int) { + draggingIndex = index + dragOffset = 0f + } + + fun onDragStopped() { + draggingIndex = null + dragOffset = 0f + } + + fun onDrag(delta: Float) { + val currentIndex = draggingIndex ?: return + val itemHeight = itemHeights[currentIndex] ?: return + + dragOffset += delta + + if (dragOffset > itemHeight / 2f && currentIndex < itemCount() - 1) { + onMove(currentIndex, currentIndex + 1) + draggingIndex = currentIndex + 1 + dragOffset -= itemHeight + } else if (dragOffset < -itemHeight / 2f && currentIndex > 0) { + onMove(currentIndex, currentIndex - 1) + draggingIndex = currentIndex - 1 + dragOffset += itemHeight + } + } +} + +@Composable +fun rememberColumnDragDropState( + itemCount: Int, + onMove: (fromIndex: Int, toIndex: Int) -> Unit, +): ColumnDragDropState { + val latestItemCount by rememberUpdatedState(itemCount) + val latestOnMove by rememberUpdatedState(onMove) + + return remember { + ColumnDragDropState( + itemCount = { latestItemCount }, + onMove = { fromIndex, toIndex -> latestOnMove(fromIndex, toIndex) }, + ) + } +} + +/** + * Wraps a single item of a [ColumnDragDropState]-driven list. Must be called inside a + * [LookaheadScope] so the other items can animate into place while one is being dragged. + * + * [content] must apply the given `heightModifier` to only the draggable part of the item, not to + * any extra decoration rendered below it (such as a separator), otherwise the drag distance + * needed to swap items would be thrown off by that decoration's height. + */ +@Composable +fun LookaheadScope.DraggableColumnItem( + dragDropState: ColumnDragDropState, + index: Int, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.(isDragging: Boolean, heightModifier: Modifier) -> Unit, +) { + val isDragging = dragDropState.draggingIndex == index + + // Only animate the items being displaced by the drag. The dragging item's position is + // driven directly by the touch via translationY below, so animating its bounds too would + // fight with that and make it lag behind the finger. + val placementModifier = if (isDragging) Modifier else Modifier.animateBounds(this) + + Column( + modifier = modifier + .then(placementModifier) + .zIndex(if (isDragging) 1f else 0f) + .graphicsLayer { translationY = if (isDragging) dragDropState.dragOffset else 0f }, + ) { + val heightModifier = Modifier.onSizeChanged { + dragDropState.onSizeChanged(index, it.height) + } + content(isDragging, heightModifier) + } +} From cb1128600e53b47e277a00ec8885576e9dbfd123 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 12:31:36 +0200 Subject: [PATCH 063/101] replace new screen off switch with the old tip card in key map options --- .../base/keymaps/KeyMapOptionsScreen.kt | 76 +++++-------------- base/src/main/res/values/strings.xml | 1 + 2 files changed, 22 insertions(+), 55 deletions(-) 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 57daf45614..661526ae20 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 @@ -18,7 +18,6 @@ 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.Route -import androidx.compose.material.icons.outlined.Science import androidx.compose.material.icons.outlined.Sensors import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material.icons.outlined.Timer @@ -28,7 +27,6 @@ import androidx.compose.material3.CircularProgressIndicator 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 @@ -45,6 +43,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +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 @@ -115,11 +114,29 @@ private fun Loaded( Column( modifier = modifier .verticalScroll(rememberScrollState()) - .fillMaxWidth().padding(horizontal = 16.dp), + .fillMaxWidth() + .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Spacer(modifier = Modifier.height(8.dp)) + if (state.showScreenOffTip) { + TipCard( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.tip_screen_off_trigger_title), + message = stringResource(R.string.tip_screen_off_trigger_message), + isDismissable = false, + buttonText = if (state.isExpertModeStarted) { + null + } else { + stringResource(R.string.button_enable_expert_mode) + }, + onButtonClick = callback::onOpenExpertModeSettings, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + OptionsHeaderRow( icon = Icons.Outlined.Info, text = stringResource(R.string.key_map_options_header_information), @@ -131,12 +148,7 @@ private fun Loaded( keyMapUid = state.keyMapUid, ) - ScreenOffSection( - modifier = Modifier.fillMaxWidth(), - isEvdevTrigger = !state.showScreenOffTip, - isExpertModeStarted = state.isExpertModeStarted, - onEnableExpertModeClick = callback::onOpenExpertModeSettings, - ) + Spacer(modifier = Modifier.height(4.dp)) OptionsHeaderRow( icon = Icons.Outlined.Sensors, @@ -345,52 +357,6 @@ private fun KeyMapUidRow(modifier: Modifier = Modifier, keyMapUid: String) { } } -@Composable -private fun ScreenOffSection( - modifier: Modifier = Modifier, - isEvdevTrigger: Boolean, - isExpertModeStarted: Boolean, - onEnableExpertModeClick: () -> Unit, -) { - Column(modifier) { - OptionsHeaderRow( - icon = Icons.Outlined.Science, - text = stringResource(R.string.key_map_options_header_screen_off), - ) - - Spacer(Modifier.height(8.dp)) - - SwitchPreferenceCompose( - modifier = Modifier.fillMaxWidth(), - title = stringResource(R.string.key_map_options_screen_off_title), - // Explain how to fix this when the trigger was not recorded with Expert Mode. - text = if (isEvdevTrigger) { - null - } else { - stringResource(R.string.tip_screen_off_trigger_message) - }, - icon = Icons.Outlined.Science, - isChecked = isEvdevTrigger, - onCheckedChange = {}, - // This cannot be turned on and off here. It depends on how the trigger was recorded. - isEnabled = false, - ) - - if (!isExpertModeStarted) { - Spacer(Modifier.height(8.dp)) - - OutlinedButton( - modifier = Modifier - .align(Alignment.End) - .padding(horizontal = 16.dp), - onClick = onEnableExpertModeClick, - ) { - Text(stringResource(R.string.button_enable_expert_mode)) - } - } - } -} - interface KeyMapOptionsCallback { fun onLongPressDelayChanged(delay: Int) = run { } fun onDoublePressDelayChanged(delay: Int) = run { } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index aaf2cefb4c..db365c8047 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -514,6 +514,7 @@ There is a timeout to input this trigger. You can change this timeout in the "Options" tab. How to use this trigger Sequence triggers + Trigger when screen is off This key map cannot be detected when the screen is off because the trigger was not recorded with Expert Mode. Re-record the trigger with Expert Mode to enable screen-off remapping. Android doesn\'t allow apps to get a list of connected (not paired) Bluetooth devices. Apps can only detect when they are connected and disconnected. So if your Bluetooth device is already connected to your device when the accessibility service starts, you will have to reconnect it for the app to know it is connected. Automatic backup From 5d9e4067f65d6c8d5abc026eba6539327ac1e614 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 12:37:32 +0200 Subject: [PATCH 064/101] use different icons for Enable/disable by intent and trigger by intent options --- .../base/keymaps/KeyMapOptionsScreen.kt | 9 +- .../base/utils/ui/compose/icons/Switch.kt | 83 +++++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/Switch.kt 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 661526ae20..94e327b7a9 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 @@ -17,9 +17,9 @@ 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.Terminal import androidx.compose.material.icons.outlined.Timer import androidx.compose.material.icons.outlined.Vibration import androidx.compose.material.icons.rounded.ContentCopy @@ -54,6 +54,7 @@ import io.github.sds100.keymapper.base.utils.ui.compose.SwitchPreferenceCompose 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.base.utils.ui.compose.icons.Switch import io.github.sds100.keymapper.common.utils.State import kotlinx.coroutines.launch @@ -272,7 +273,7 @@ private fun Loaded( 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.Outlined.Terminal, + icon = KeyMapperIcons.Switch, onClick = callback::onEnableByIntentClick, ) SwitchPreferenceCompose( @@ -288,7 +289,7 @@ private fun Loaded( 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.Terminal, + icon = Icons.Outlined.RocketLaunch, // Key Mapper only listens for these intents when other apps are allowed to // control this key map. enabled = state.triggerFromOtherApps, @@ -372,7 +373,7 @@ interface KeyMapOptionsCallback { fun onEnableByIntentClick() = run {} } -@Preview +@Preview(heightDp = 1300) @Composable private fun Preview() { KeyMapperTheme { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/Switch.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/Switch.kt new file mode 100644 index 0000000000..45906379dc --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/Switch.kt @@ -0,0 +1,83 @@ +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.Switch: ImageVector + get() { + if (_Switch != null) { + return _Switch!! + } + _Switch = ImageVector.Builder( + name = "Switch", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(240f, 880f) + quadToRelative(-33f, 0f, -56.5f, -23.5f) + reflectiveQuadTo(160f, 800f) + verticalLineToRelative(-640f) + quadToRelative(0f, -33f, 23.5f, -56.5f) + reflectiveQuadTo(240f, 80f) + horizontalLineToRelative(480f) + quadToRelative(33f, 0f, 56.5f, 23.5f) + reflectiveQuadTo(800f, 160f) + verticalLineToRelative(640f) + quadToRelative(0f, 33f, -23.5f, 56.5f) + reflectiveQuadTo(720f, 880f) + lineTo(240f, 880f) + close() + moveTo(240f, 800f) + horizontalLineToRelative(480f) + verticalLineToRelative(-640f) + lineTo(240f, 160f) + verticalLineToRelative(640f) + close() + moveTo(320f, 680f) + horizontalLineToRelative(320f) + verticalLineToRelative(-400f) + lineTo(320f, 280f) + verticalLineToRelative(400f) + close() + moveTo(400f, 600f) + verticalLineToRelative(-160f) + horizontalLineToRelative(160f) + verticalLineToRelative(160f) + lineTo(400f, 600f) + close() + moveTo(480f, 250f) + quadToRelative(13f, 0f, 21.5f, -8.5f) + reflectiveQuadTo(510f, 220f) + quadToRelative(0f, -13f, -8.5f, -21.5f) + reflectiveQuadTo(480f, 190f) + quadToRelative(-13f, 0f, -21.5f, 8.5f) + reflectiveQuadTo(450f, 220f) + quadToRelative(0f, 13f, 8.5f, 21.5f) + reflectiveQuadTo(480f, 250f) + close() + moveTo(480f, 770f) + quadToRelative(13f, 0f, 21.5f, -8.5f) + reflectiveQuadTo(510f, 740f) + quadToRelative(0f, -13f, -8.5f, -21.5f) + reflectiveQuadTo(480f, 710f) + quadToRelative(-13f, 0f, -21.5f, 8.5f) + reflectiveQuadTo(450f, 740f) + quadToRelative(0f, 13f, 8.5f, 21.5f) + reflectiveQuadTo(480f, 770f) + close() + moveTo(480f, 480f) + close() + } + }.build() + + return _Switch!! + } + +@Suppress("ObjectPropertyName") +private var _Switch: ImageVector? = null From 033eac34f6ee5dc68d65709cc0911b477b1d0391 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 12:48:40 +0200 Subject: [PATCH 065/101] #2249 feat: if all the keys are Internal or Any, then do not show any extra text for brevity --- CHANGELOG.md | 1 + .../base/home/KeyMapListItemCreator.kt | 53 +++++++++++++------ 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8180165253..4080df4015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - #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. ## Fixed 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 f70861d8e3..8e871bd9e4 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 @@ -23,6 +23,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 +64,7 @@ class KeyMapListItemCreator( is KeyEventTriggerKey -> keyEventTriggerKeyName( key, + keyMap.trigger.keys, showDeviceDescriptors, ) @@ -264,6 +266,7 @@ class KeyMapListItemCreator( private fun keyEventTriggerKeyName( key: KeyEventTriggerKey, + allKeys: List, showDeviceDescriptors: Boolean, ): String = buildString { when (key.clickType) { @@ -274,22 +277,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() @@ -314,6 +302,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(" ") From 07f96542c7d075624325a99676189926c271d976 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 13:15:23 +0200 Subject: [PATCH 066/101] #2249 feat: remove "Tap actions to fix!, Tap constraints to fix!" and grey out the key map when it is disabled --- .../base/home/HomeKeyMapListScreen.kt | 12 +- .../base/home/KeyMapListItemCreator.kt | 94 +++----- .../keymapper/base/home/KeyMapListScreen.kt | 226 +++--------------- .../base/home/KeyMapListViewModel.kt | 1 + .../shortcuts/CreateKeyMapShortcutScreen.kt | 4 +- .../base/trigger/KeyMapListItemModel.kt | 2 +- base/src/main/res/values/strings.xml | 1 + 7 files changed, 75 insertions(+), 265 deletions(-) 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 dc04ed1056..47a0e11712 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 @@ -500,7 +500,7 @@ fun sameKeyMapListItems(): List { ), options = listOf("Vibrate"), triggerErrors = listOf(TriggerError.DND_ACCESS_DENIED), - extraInfo = "Disabled • No trigger", + isEnabled = true, ), ), KeyMapListItemModel( @@ -533,7 +533,7 @@ fun sameKeyMapListItems(): List { "Vibrate when keys are initially pressed and again when long pressed", ), triggerErrors = emptyList(), - extraInfo = null, + isEnabled = true, ), ), KeyMapListItemModel( @@ -549,6 +549,7 @@ fun sameKeyMapListItems(): List { drawable = context.drawable(R.drawable.ic_launcher_web), ), "Open Key Mapper", + isEnabled = false, ), ), constraintMode = ConstraintMode.AND, @@ -559,11 +560,12 @@ fun sameKeyMapListItems(): List { drawable = context.drawable(R.drawable.ic_launcher_web), ), "Key Mapper is not open", + isEnabled = false, ), ), options = emptyList(), triggerErrors = emptyList(), - extraInfo = null, + isEnabled = false, ), ), KeyMapListItemModel( @@ -585,7 +587,7 @@ fun sameKeyMapListItems(): List { constraints = emptyList(), options = emptyList(), triggerErrors = emptyList(), - extraInfo = null, + isEnabled = true, ), ), KeyMapListItemModel( @@ -599,7 +601,7 @@ fun sameKeyMapListItems(): List { constraints = emptyList(), options = emptyList(), triggerErrors = emptyList(), - extraInfo = "Disabled • No trigger", + isEnabled = true, ), ), ) 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 8e871bd9e4..a6c9e28780 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 @@ -80,27 +80,23 @@ class KeyMapListItemCreator( 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 ") - } + buildConstraintChipList( + keyMap.constraintState, + constraintErrorSnapshot, + isEnabled = keyMap.isEnabled, + ) - append(getString(R.string.no_trigger)) - } + val triggerErrors = if (keyMap.isEnabled) { + keyMap.trigger.keys.mapNotNull { key -> + triggerErrorSnapshot.getTriggerError( + keyMap, + key, + ) + }.distinct() + } else { + emptyList() } - val triggerErrors = keyMap.trigger.keys.mapNotNull { key -> - triggerErrorSnapshot.getTriggerError( - keyMap, - key, - ) - }.distinct() - return KeyMapListItemModel.Content( uid = keyMap.uid, triggerKeys = triggerKeys, @@ -110,7 +106,7 @@ class KeyMapListItemCreator( constraints = constraintChipList, constraintMode = keyMap.constraintState.mode, options = options, - extraInfo = extraInfo.takeIf { it.isNotBlank() }, + isEnabled = keyMap.isEnabled, ) } @@ -123,7 +119,11 @@ 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) { @@ -160,13 +160,13 @@ class KeyMapListItemCreator( val icon: ComposeIconInfo = actionUiHelper.getIcon(action.data) val error: KMError? = actionErrors[action.data] - // Disabled actions are never performed so do not show their errors. - val chip = if (error == null || !action.isEnabled) { + // 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 = action.isEnabled, + isEnabled = keyMap.isEnabled && action.isEnabled, ) } else { ComposeChipModel.Error(action.uid, chipText, error, isFixable = error.isFixable) @@ -179,62 +179,34 @@ class KeyMapListItemCreator( fun buildConstraintChipList( constraintState: ConstraintState, errorSnapshot: ConstraintErrorSnapshot, + isEnabled: Boolean, ): 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) { + // 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) + ComposeChipModel.Error( + constraint.uid, + text, + error, + error.isFixable, + ) } yield(chip) } }.toList() - private fun createExtraInfoString( - keyMap: KeyMap, - actionChipList: List, - constraintChipList: List, - ) = buildString { - val midDot by lazy { getString(R.string.middot) } - - if (!keyMap.isEnabled) { - append(getString(R.string.switch_disabled)) - } - - if (actionChipList.any { it is ComposeChipModel.Error }) { - if (this.isNotEmpty()) { - append(" $midDot ") - } - - append(getString(R.string.tap_actions_to_fix)) - } - - if (constraintChipList.any { it is ComposeChipModel.Error }) { - if (this.isNotEmpty()) { - append(" $midDot ") - } - - append(getString(R.string.tap_constraints_to_fix)) - } - - if (actionChipList.isEmpty()) { - if (this.isNotEmpty()) { - append(" $midDot ") - } - - append(getString(R.string.no_actions)) - } - } - private fun floatingButtonKeyName(key: FloatingButtonKey): String = buildString { when (key.clickType) { ClickType.LONG_PRESS -> append(longPressString).append(" ") 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 d6e0de0ec3..aa45ade8ea 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 @@ -21,10 +21,7 @@ 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.CardDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -46,7 +43,6 @@ 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 @@ -70,7 +66,6 @@ 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 @@ -246,8 +241,18 @@ private fun KeyMapListItem( onFixClick: (KMError) -> Unit, onTriggerErrorClick: (TriggerError) -> 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( @@ -275,30 +280,15 @@ private fun KeyMapListItem( .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, - ) - } + 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()) { @@ -412,13 +402,17 @@ private fun TriggerDescription( pop() append(" ") - for ((index, key) in triggerKeys.withIndex()) { - append(key) + 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(" ") + if (index < triggerKeys.lastIndex) { + append(" ") + appendInlineContent("separator") + append(" ") + } } } } @@ -559,166 +553,6 @@ private fun getTriggerErrorMessage(error: TriggerError): String { } } -@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", - isEnabled = false, - ), - 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), - isEnabled = false, - ), - ), - 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", - isEnabled = false, - ), - ), - 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() { 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 0ce48e8839..18390ac340 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 @@ -451,6 +451,7 @@ class KeyMapListViewModel( constraints = listItemCreator.buildConstraintChipList( keyMapGroup.group.constraintState, constraintErrorSnapshot, + isEnabled = true, ), constraintMode = keyMapGroup.group.constraintState.mode, parentConstraintCount = keyMapGroup.parents.sumOf { 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 1448d46ea7..b3c92ec347 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 @@ -309,7 +309,7 @@ private fun keyMapSampleList(): List { ), options = listOf("Vibrate"), triggerErrors = listOf(TriggerError.DND_ACCESS_DENIED), - extraInfo = null, + isEnabled = false, ), ), KeyMapListItemModel( @@ -323,7 +323,7 @@ private fun keyMapSampleList(): List { constraints = emptyList(), options = emptyList(), triggerErrors = emptyList(), - extraInfo = "Disabled • No trigger", + isEnabled = true, ), ), ) 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..1dc0a93000 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 @@ -16,6 +16,6 @@ data class KeyMapListItemModel(val isSelected: Boolean, val content: Content) { val constraintMode: ConstraintMode, val constraints: List, val options: List, - val extraInfo: String?, + val isEnabled: Boolean, ) } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index db365c8047..79ba2c5265 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -122,6 +122,7 @@ Options: Actions: Trigger: + None Constraints: Swipe up Swipe down From 8c3f04e09581a2aff8273a88615a680c0d627183 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 13:26:45 +0200 Subject: [PATCH 067/101] fix animation bugs with action and constraint group list items --- .../keymapper/base/actions/ActionListItem.kt | 18 ++---------------- .../base/constraints/ConstraintGroupItem.kt | 5 ++--- 2 files changed, 4 insertions(+), 19 deletions(-) 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 fc2ea601d9..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,10 +1,9 @@ package io.github.sds100.keymapper.base.actions import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.BoundsTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition -import androidx.compose.animation.animateBounds +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -111,10 +110,6 @@ private val renameButtonExitTransition: ExitTransition = shrinkTowards = Alignment.CenterStart, ) -private val headerBoundsTransform = BoundsTransform { _, _ -> - tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing) -} - @Composable fun ActionListItem( modifier: Modifier = Modifier, @@ -162,17 +157,8 @@ fun ActionListItem( Spacer(Modifier.width(8.dp)) - // The summary shrinks while the rename button grows, so the header's natural - // height dips before reaching its final height. Animate straight to the final - // size instead so the drag handle, icon and chevron do not bounce. HeaderText( - modifier = Modifier - .weight(1f) - .animateBounds( - lookaheadScope = this, - modifier = Modifier.padding(vertical = 8.dp), - boundsTransform = headerBoundsTransform, - ), + modifier = Modifier.weight(1f).animateContentSize().padding(vertical = 8.dp), model = model, isExpanded = expanded, onRenameClick = onRenameClick, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt index 44faa40432..a099b3b422 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt @@ -3,6 +3,7 @@ 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 @@ -38,9 +39,7 @@ 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.runtime.key -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.LookaheadScope @@ -204,7 +203,7 @@ private fun HeaderText( Row(verticalAlignment = Alignment.CenterVertically) { Text( modifier = Modifier - .weight(1f, fill = false), + .weight(1f, fill = false).animateContentSize(), text = title, style = MaterialTheme.typography.bodyMedium, maxLines = 2, From b664d9b308dd2c4e4937620d27701c4efcda5406 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 13:56:05 +0200 Subject: [PATCH 068/101] #2258 fix: remember saved position in key map list when changing them. Do not flash with loading indicator briefly as well. --- CHANGELOG.md | 1 + .../keymapper/base/home/ListKeyMapsUseCase.kt | 59 ++++++++----------- 2 files changed, 26 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4080df4015..41335bd146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ ## 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. ## [4.4.1](https://github.com/sds100/KeyMapper/releases/tag/v4.4.1) 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 3439384a29..c62cc3f44f 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 @@ -37,14 +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 @@ -89,27 +86,23 @@ class ListKeyMapsUseCaseImpl @Inject constructor( } } - override val keyMapGroup: Flow = channelFlow { - keyMapListGroupUid - .flatMapLatest(::getGroupFamily) - .flatMapLatest { groupFamily -> - val parentGroups = getParentsRecursively(groupFamily.group?.uid) - - getSubGroupsWithState(groupFamily.children).map { subGroups -> - KeyMapGroup( - group = groupFamily.group, - subGroups = subGroups, - keyMaps = State.Loading, - parents = parentGroups, - ) - } - }.onEach { send(it) } - .flatMapLatest { keyMapGroup -> - getKeyMapsByGroup(keyMapGroup.group?.uid).map { keyMapGroup.copy(keyMaps = it) } - }.collect { - send(it) + 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 = subGroups, + keyMaps = State.Loading, + parents = parentGroups, + ) } - } + } + .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 @@ -394,20 +387,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 -> @@ -415,7 +406,7 @@ class ListKeyMapsUseCaseImpl @Inject constructor( } } - send(State.Data(keyMaps)) + State.Data(keyMaps) } } From 82480bd56534ff1b74366077ef32be78f8822413 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 13:56:38 +0200 Subject: [PATCH 069/101] replace group constraints AND/OR switcher with segmented buttons instead of radio buttons --- .../base/constraints/ConstraintModeButtons.kt | 4 +++ .../keymapper/base/home/KeyMapListHeader.kt | 36 +++++++------------ .../ui/compose/KeyMapperSegmentedButtonRow.kt | 6 ++-- 3 files changed, 21 insertions(+), 25 deletions(-) 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 index bf1a6e73a9..a40ece261d 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -21,6 +23,7 @@ fun ConstraintModeButtons( onSelectMode: (ConstraintMode) -> Unit, modifier: Modifier = Modifier, isEnabled: Boolean = true, + colors: SegmentedButtonColors = SegmentedButtonDefaults.colors(), ) { KeyMapperSegmentedButtonRow( modifier = modifier, @@ -32,6 +35,7 @@ fun ConstraintModeButtons( onStateSelected = onSelectMode, isCompact = true, isEnabled = isEnabled, + colors = colors, ) } 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 20d1b27073..5e30daec50 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 @@ -17,12 +17,12 @@ 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 @@ -38,13 +38,13 @@ 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.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 @@ -200,29 +200,19 @@ private fun ChildGroupHeader( horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { + val modeColors = SegmentedButtonDefaults.colors( + activeContainerColor = MaterialTheme.colorScheme.primary, + activeContentColor = MaterialTheme.colorScheme.onPrimary, + activeBorderColor = MaterialTheme.colorScheme.onPrimaryContainer, + inactiveBorderColor = 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, ) } } 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..3695568420 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 @@ -37,12 +38,13 @@ fun KeyMapperSegmentedButtonRow( onStateSelected: (T) -> Unit, isCompact: Boolean = false, isEnabled: Boolean = true, + colors: SegmentedButtonColors = SegmentedButtonDefaults.colors(), ) { val colors = if (isEnabled) { - SegmentedButtonDefaults.colors() + 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), ) From 829bd426313d0f63cc2422602e88e617a75d10ba Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 13:59:04 +0200 Subject: [PATCH 070/101] #2259 feat: all key maps can now be triggered by intent or shortcut without needing to enable it first --- CHANGELOG.md | 1 + .../base/detection/DetectKeyMapsUseCase.kt | 6 ------ .../TriggerKeyMapFromOtherAppsController.kt | 4 ++-- .../base/home/KeyMapListItemCreator.kt | 4 ---- .../keymaps/ConfigKeyMapOptionsViewModel.kt | 6 ------ .../base/keymaps/KeyMapOptionsScreen.kt | 17 +---------------- .../shortcuts/CreateKeyMapShortcutViewModel.kt | 3 --- .../comparators/KeyMapOptionsComparator.kt | 1 - .../base/trigger/ConfigTriggerDelegate.kt | 4 ---- .../base/trigger/ConfigTriggerUseCase.kt | 7 ------- .../sds100/keymapper/base/trigger/Trigger.kt | 6 ------ base/src/main/res/values-pt/strings.xml | 1 - base/src/main/res/values-tr/strings.xml | 1 - base/src/main/res/values-zh-rCN/strings.xml | 1 - base/src/main/res/values/strings.xml | 3 --- .../TriggerKeyMapFromOtherAppsControllerTest.kt | 9 ++++----- .../keymapper/data/entities/TriggerEntity.kt | 3 +++ 17 files changed, 11 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41335bd146..01108a056d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - #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. ## Fixed 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 7754d823ae..9b18691dda 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 @@ -137,11 +137,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 } @@ -262,7 +257,6 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor( interface DetectKeyMapsUseCase { val allKeyMapList: Flow> val requestFingerprintGestureDetection: Flow - val keyMapsToTriggerFromOtherApps: Flow> val defaultLongPressDelay: Flow val defaultDoublePressDelay: Flow 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/home/KeyMapListItemCreator.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt index a6c9e28780..559f6312ec 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 @@ -381,10 +381,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/keymaps/ConfigKeyMapOptionsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt index bac571be9a..7cb08841e3 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 @@ -85,10 +85,6 @@ 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) @@ -210,7 +206,6 @@ class ConfigKeyMapOptionsViewModel( showLongPressDoubleVibration = keyMap.trigger.isLongPressDoubleVibrationAllowed(), longPressDoubleVibration = keyMap.trigger.longPressDoubleVibration, - triggerFromOtherApps = keyMap.trigger.triggerFromOtherApps, keyMapUid = keyMap.uid, isLauncherShortcutButtonEnabled = createKeyMapShortcut.isSupported, @@ -251,7 +246,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/KeyMapOptionsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt index 94e327b7a9..2b0a81479c 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 @@ -276,23 +276,11 @@ private fun Loaded( icon = KeyMapperIcons.Switch, onClick = callback::onEnableByIntentClick, ) - SwitchPreferenceCompose( - modifier = Modifier.fillMaxWidth(), - title = stringResource(R.string.key_map_options_trigger_from_other_apps_title), - text = stringResource(R.string.key_map_options_trigger_from_other_apps_summary), - icon = Icons.Outlined.Route, - isChecked = state.triggerFromOtherApps, - onCheckedChange = callback::onTriggerFromOtherAppsChanged, - ) - 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, - // Key Mapper only listens for these intents when other apps are allowed to - // control this key map. - enabled = state.triggerFromOtherApps, onClick = callback::onTriggerByIntentClick, ) @@ -305,7 +293,7 @@ private fun Loaded( stringResource(R.string.key_map_options_create_shortcut_summary_unsupported) }, icon = Icons.Outlined.AddHome, - enabled = state.triggerFromOtherApps && state.isLauncherShortcutButtonEnabled, + enabled = state.isLauncherShortcutButtonEnabled, onClick = callback::onCreateShortcutClick, ) @@ -366,7 +354,6 @@ interface KeyMapOptionsCallback { 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 {} @@ -403,7 +390,6 @@ private fun Preview() { showLongPressDoubleVibration = true, longPressDoubleVibration = false, - triggerFromOtherApps = true, keyMapUid = "beea7ef5-e33e-4bd3-9987-9002e5035f23", isLauncherShortcutButtonEnabled = false, @@ -448,7 +434,6 @@ private fun PreviewEvdevTrigger() { showLongPressDoubleVibration = false, longPressDoubleVibration = false, - triggerFromOtherApps = false, keyMapUid = "beea7ef5-e33e-4bd3-9987-9002e5035f23", isLauncherShortcutButtonEnabled = true, 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 e511b858a5..bee979949f 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, @@ -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() 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/trigger/ConfigTriggerDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegate.kt index 8839dccb64..2bb4e4db21 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 @@ -479,10 +479,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..d0164dc3e6 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 @@ -238,12 +238,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) @@ -338,7 +332,6 @@ interface ConfigTriggerUseCase : GetDefaultKeyMapOptionsUseCase { 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/Trigger.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/Trigger.kt index 4150648257..232c382840 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 @@ -26,7 +26,6 @@ data class Trigger( val doublePressDelay: Int? = null, val vibrateDuration: Int? = null, val sequenceTriggerTimeout: Int? = null, - val triggerFromOtherApps: Boolean = false, val showToast: Boolean = false, /** @@ -135,7 +134,6 @@ object TriggerEntityMapper { ) .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, @@ -200,10 +198,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) } 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..e7830b0399 100644 --- a/base/src/main/res/values-zh-rCN/strings.xml +++ b/base/src/main/res/values-zh-rCN/strings.xml @@ -553,7 +553,6 @@ 按住 保持直到再次按下 不要重映射 - 允许其他应用通过 Intent 或快捷方式控制此按键映射 复制按键映射 id diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 79ba2c5265..8d1f253eba 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -807,7 +807,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 @@ -2264,8 +2263,6 @@ 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 - Allow other apps to control this key map - Required to trigger this key map with an intent or a shortcut Trigger by intent Enable/disable by intent Use key maps with Tasker, Automate, etc. 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/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..c4ebb5d964 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 From 4089a376c2df0709777aa20aa1ba8ab5fc6369bc Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 14:19:52 +0200 Subject: [PATCH 071/101] #752 do not allow reordering constraints within a group This is to save horizontal space and reduce complexity --- .../constraints/ConfigConstraintsUseCase.kt | 9 - .../constraints/ConfigConstraintsViewModel.kt | 4 - .../base/constraints/ConstraintListItem.kt | 156 +----------------- .../base/constraints/ConstraintsScreen.kt | 7 - .../base/groups/GroupConstraintRow.kt | 35 +++- .../utils/ui/compose/ColumnDragDropState.kt | 123 -------------- base/src/main/res/values/strings.xml | 1 + 7 files changed, 32 insertions(+), 303 deletions(-) delete mode 100644 base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ColumnDragDropState.kt 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 6cc67f5f89..56f2296b81 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 @@ -168,14 +168,6 @@ class ConfigConstraintsUseCaseImpl @Inject constructor( } } - override fun moveConstraint(groupUid: String, fromIndex: Int, toIndex: Int) { - updateConstraintState { oldState -> - oldState.updateGroup(groupUid) { group -> - group.copy(constraints = group.constraints.move(fromIndex, toIndex)) - } - } - } - private fun updateConstraintState(block: (ConstraintState) -> ConstraintState): KeyMap? { return state.update { keyMap -> keyMap.copy(constraintState = block(keyMap.constraintState)) @@ -250,5 +242,4 @@ interface ConfigConstraintsUseCase { fun setGroupMode(groupUid: String, mode: ConstraintMode) fun setGroupName(groupUid: String, name: String?) fun moveGroup(fromIndex: Int, toIndex: Int) - fun moveConstraint(groupUid: String, 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 41d158251b..3e0847eaa5 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 @@ -140,10 +140,6 @@ class ConfigConstraintsViewModel @Inject constructor( fun onMoveGroup(fromIndex: Int, toIndex: Int) = config.moveGroup(fromIndex, toIndex) - fun onMoveConstraint(groupUid: String, fromIndex: Int, toIndex: Int) { - config.moveConstraint(groupUid, fromIndex, toIndex) - } - fun onFixError(constraintUid: String) { viewModelScope.launch { val constraint = config.keyMap 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 086ff53d9e..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,9 +1,7 @@ package io.github.sds100.keymapper.base.constraints import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -16,7 +14,6 @@ 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.DragHandle import androidx.compose.material.icons.rounded.Error import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults @@ -34,10 +31,8 @@ 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.CustomAccessibilityAction import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -57,45 +52,13 @@ import io.github.sds100.keymapper.base.utils.ui.drawable fun ConstraintListItem( modifier: Modifier = Modifier, model: ConstraintListItemModel, - isReorderingEnabled: Boolean = false, isDragging: Boolean = false, - /** - * The modifier that makes the drag handle draggable. - */ - dragHandleModifier: Modifier = Modifier, onRemoveClick: () -> Unit = {}, onFixClick: () -> Unit = {}, onNotClick: () -> Unit = {}, - onMoveUp: (() -> Unit)? = null, - onMoveDown: (() -> Unit)? = null, ) { - val moveUpLabel = stringResource(R.string.accessibility_action_move_up) - val moveDownLabel = stringResource(R.string.accessibility_action_move_down) - ElevatedCard( - modifier = modifier - .semantics { - if (isReorderingEnabled) { - customActions = buildList { - onMoveUp?.let { action -> - add( - CustomAccessibilityAction(moveUpLabel) { - action() - true - }, - ) - } - onMoveDown?.let { action -> - add( - CustomAccessibilityAction(moveDownLabel) { - action() - true - }, - ) - } - } - } - }, + modifier = modifier, colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, @@ -113,23 +76,9 @@ fun ConstraintListItem( BoxWithConstraints { when { - maxWidth < 300.dp -> { - ConstraintListItemSmall( - modifier = listItemModifier, - isReorderingEnabled = isReorderingEnabled, - dragHandleModifier = dragHandleModifier, - model = model, - onFixClick = onFixClick, - onNotClick = onNotClick, - onRemoveClick = onRemoveClick, - ) - } - maxWidth < 400.dp -> { ConstraintListItemMedium( modifier = listItemModifier, - isReorderingEnabled = isReorderingEnabled, - dragHandleModifier = dragHandleModifier, model = model, onFixClick = onFixClick, onNotClick = onNotClick, @@ -140,8 +89,6 @@ fun ConstraintListItem( else -> { ConstraintListItemLarge( modifier = listItemModifier, - isReorderingEnabled = isReorderingEnabled, - dragHandleModifier = dragHandleModifier, model = model, onFixClick = onFixClick, onNotClick = onNotClick, @@ -153,81 +100,9 @@ fun ConstraintListItem( } } -@Composable -private fun ConstraintListItemSmall( - modifier: Modifier = Modifier, - isReorderingEnabled: Boolean, - dragHandleModifier: Modifier, - model: ConstraintListItemModel, - onFixClick: () -> Unit, - onNotClick: () -> Unit, - onRemoveClick: () -> Unit, -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(Modifier.width(8.dp)) - - if (isReorderingEnabled) { - Icon( - modifier = Modifier - .size(24.dp) - .then(dragHandleModifier), - imageVector = Icons.Rounded.DragHandle, - contentDescription = stringResource(R.string.drag_handle_for, model.text), - ) - - Spacer(Modifier.width(8.dp)) - } - - Column(modifier = Modifier.weight(1f)) { - Text( - modifier = Modifier.fillMaxWidth(), - text = model.text, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - ) { - if (model.error != null && model.isErrorFixable) { - CompactErrorButton(onClick = onFixClick) { - Text(stringResource(R.string.button_fix)) - } - } - - Spacer(Modifier.width(8.dp)) - - NotToggle(isNot = model.isNot, onClick = onNotClick) - } - } - - CompositionLocalProvider( - LocalMinimumInteractiveComponentSize provides 16.dp, - ) { - IconButton(onClick = onRemoveClick) { - Icon( - modifier = Modifier.size(24.dp), - imageVector = Icons.Rounded.Clear, - contentDescription = stringResource( - R.string.constraint_list_item_remove, - ), - ) - } - } - } -} - @Composable private fun ConstraintListItemLarge( modifier: Modifier = Modifier, - isReorderingEnabled: Boolean, - dragHandleModifier: Modifier, model: ConstraintListItemModel, onFixClick: () -> Unit, onNotClick: () -> Unit, @@ -239,18 +114,6 @@ private fun ConstraintListItemLarge( ) { Spacer(Modifier.width(8.dp)) - if (isReorderingEnabled) { - Icon( - modifier = Modifier - .size(24.dp) - .then(dragHandleModifier), - imageVector = Icons.Rounded.DragHandle, - contentDescription = stringResource(R.string.drag_handle_for, model.text), - ) - - Spacer(Modifier.width(8.dp)) - } - ConstraintIcon(icon = model.icon) Spacer(Modifier.width(8.dp)) @@ -290,8 +153,6 @@ private fun ConstraintListItemLarge( @Composable private fun ConstraintListItemMedium( modifier: Modifier = Modifier, - isReorderingEnabled: Boolean, - dragHandleModifier: Modifier, model: ConstraintListItemModel, onFixClick: () -> Unit, onNotClick: () -> Unit, @@ -303,18 +164,6 @@ private fun ConstraintListItemMedium( ) { Spacer(Modifier.width(8.dp)) - if (isReorderingEnabled) { - Icon( - modifier = Modifier - .size(24.dp) - .then(dragHandleModifier), - imageVector = Icons.Rounded.DragHandle, - contentDescription = stringResource(R.string.drag_handle_for, model.text), - ) - - Spacer(Modifier.width(8.dp)) - } - ConstraintIcon(icon = model.icon) Spacer(Modifier.width(8.dp)) @@ -425,7 +274,6 @@ private fun VectorPreview() { icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), text = "Clear all", ), - isReorderingEnabled = true, ) } } @@ -444,7 +292,6 @@ private fun NotErrorPreview() { error = "Permission need to control camera", isErrorFixable = true, ), - isReorderingEnabled = true, ) } } @@ -461,7 +308,6 @@ private fun DrawablePreview() { text = "Key Mapper is in foreground", icon = ComposeIconInfo.Drawable(drawable), ), - isReorderingEnabled = true, ) } } 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 b3a6bf40bb..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 @@ -89,7 +89,6 @@ fun ConstraintsScreen( onRenameGroup = viewModel::onRenameGroup, onExpandedChange = viewModel::onExpandedChange, onMoveGroup = viewModel::onMoveGroup, - onMoveConstraint = viewModel::onMoveConstraint, ) } @@ -109,7 +108,6 @@ private fun ConstraintsScreen( onRenameGroup: (groupUid: String, name: String) -> Unit = { _, _ -> }, onExpandedChange: (groupUid: String, expanded: Boolean) -> Unit = { _, _ -> }, onMoveGroup: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }, - onMoveConstraint: (groupUid: String, fromIndex: Int, toIndex: Int) -> Unit = { _, _, _ -> }, ) { var constraintToDelete by rememberSaveable { mutableStateOf(null) } var groupToDelete by rememberSaveable { mutableStateOf(null) } @@ -239,7 +237,6 @@ private fun ConstraintsScreen( onRenameGroupClick = { groupToRename = it }, onExpandedChange = onExpandedChange, onMoveGroup = onMoveGroup, - onMoveConstraint = onMoveConstraint, ) if (data.groups.size > 1) { @@ -303,7 +300,6 @@ private fun ConstraintGroupList( onRenameGroupClick: (groupUid: String) -> Unit, onExpandedChange: (groupUid: String, expanded: Boolean) -> Unit, onMoveGroup: (fromIndex: Int, toIndex: Int) -> Unit, - onMoveConstraint: (groupUid: String, fromIndex: Int, toIndex: Int) -> Unit, ) { val lazyListState = rememberLazyListState() val groupUids = state.groups.map { it.uid } @@ -366,9 +362,6 @@ private fun ConstraintGroupList( onRemoveConstraintClick = onRemoveClick, onFixConstraintClick = onFixErrorClick, onNotClick = onNotClick, - onMoveConstraint = { fromIndex, toIndex -> - onMoveConstraint(group.uid, fromIndex, toIndex) - }, onMoveUp = if (index > 0) { { onMoveGroup(index, index - 1) } } else { 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..dba8a92f6c 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 @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.SwapHorizontalCircle import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.ErrorOutline @@ -71,8 +72,6 @@ fun GroupConstraintRow( 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( @@ -195,7 +194,7 @@ private fun ConstraintButton( text: String, icon: @Composable () -> Unit, onRemoveClick: () -> Unit = {}, - enabled: Boolean, + onNotClick: () -> Unit = {}, ) { CompositionLocalProvider( LocalMinimumInteractiveComponentSize provides 16.dp, @@ -223,10 +222,23 @@ private fun ConstraintButton( Spacer(modifier = Modifier.width(4.dp)) + IconButton( + modifier = Modifier.size(16.dp), + onClick = onNotClick, + ) { + Icon( + imageVector = Icons.Outlined.SwapHorizontalCircle, + contentDescription = stringResource( + R.string.home_group_invert_constraint_button, + ), + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + IconButton( modifier = Modifier.size(16.dp), onClick = onRemoveClick, - enabled = enabled, ) { Icon( imageVector = Icons.Rounded.Close, @@ -247,6 +259,7 @@ private fun ConstraintErrorButton( onClick: () -> Unit, onRemoveClick: () -> Unit = {}, enabled: Boolean, + onNotClick: () -> Unit = {}, ) { CompositionLocalProvider( LocalMinimumInteractiveComponentSize provides 16.dp, @@ -279,13 +292,25 @@ private fun ConstraintErrorButton( style = MaterialTheme.typography.titleSmall, overflow = TextOverflow.Ellipsis, ) + Spacer(modifier = Modifier.width(4.dp)) + + IconButton( + modifier = Modifier.size(16.dp), + onClick = onNotClick, + ) { + Icon( + imageVector = Icons.Outlined.SwapHorizontalCircle, + contentDescription = stringResource( + R.string.home_group_invert_constraint_button, + ), + ) + } Spacer(modifier = Modifier.width(4.dp)) IconButton( modifier = Modifier.size(16.dp), onClick = onRemoveClick, - enabled = enabled, ) { Icon( imageVector = Icons.Rounded.Close, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ColumnDragDropState.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ColumnDragDropState.kt deleted file mode 100644 index 2e06c4c6f8..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ColumnDragDropState.kt +++ /dev/null @@ -1,123 +0,0 @@ -package io.github.sds100.keymapper.base.utils.ui.compose - -import androidx.compose.animation.animateBounds -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.LookaheadScope -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.zIndex - -/** - * Drag-and-drop state for reordering items in a plain [Column] rather than a lazy list. Unlike - * [DragDropState], which compares on-screen bounds, items are swapped one at a time as soon as - * the dragged item has moved over half of its own height. - */ -@Stable -class ColumnDragDropState internal constructor( - private val itemCount: () -> Int, - private val onMove: (fromIndex: Int, toIndex: Int) -> Unit, -) { - var draggingIndex by mutableStateOf(null) - private set - - var dragOffset by mutableFloatStateOf(0f) - private set - - /** - * The last measured height of whichever item is currently laid out at each index. - */ - private val itemHeights = mutableStateMapOf() - - internal fun onSizeChanged(index: Int, height: Int) { - itemHeights[index] = height - } - - fun onDragStarted(index: Int) { - draggingIndex = index - dragOffset = 0f - } - - fun onDragStopped() { - draggingIndex = null - dragOffset = 0f - } - - fun onDrag(delta: Float) { - val currentIndex = draggingIndex ?: return - val itemHeight = itemHeights[currentIndex] ?: return - - dragOffset += delta - - if (dragOffset > itemHeight / 2f && currentIndex < itemCount() - 1) { - onMove(currentIndex, currentIndex + 1) - draggingIndex = currentIndex + 1 - dragOffset -= itemHeight - } else if (dragOffset < -itemHeight / 2f && currentIndex > 0) { - onMove(currentIndex, currentIndex - 1) - draggingIndex = currentIndex - 1 - dragOffset += itemHeight - } - } -} - -@Composable -fun rememberColumnDragDropState( - itemCount: Int, - onMove: (fromIndex: Int, toIndex: Int) -> Unit, -): ColumnDragDropState { - val latestItemCount by rememberUpdatedState(itemCount) - val latestOnMove by rememberUpdatedState(onMove) - - return remember { - ColumnDragDropState( - itemCount = { latestItemCount }, - onMove = { fromIndex, toIndex -> latestOnMove(fromIndex, toIndex) }, - ) - } -} - -/** - * Wraps a single item of a [ColumnDragDropState]-driven list. Must be called inside a - * [LookaheadScope] so the other items can animate into place while one is being dragged. - * - * [content] must apply the given `heightModifier` to only the draggable part of the item, not to - * any extra decoration rendered below it (such as a separator), otherwise the drag distance - * needed to swap items would be thrown off by that decoration's height. - */ -@Composable -fun LookaheadScope.DraggableColumnItem( - dragDropState: ColumnDragDropState, - index: Int, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.(isDragging: Boolean, heightModifier: Modifier) -> Unit, -) { - val isDragging = dragDropState.draggingIndex == index - - // Only animate the items being displaced by the drag. The dragging item's position is - // driven directly by the touch via translationY below, so animating its bounds too would - // fight with that and make it lag behind the finger. - val placementModifier = if (isDragging) Modifier else Modifier.animateBounds(this) - - Column( - modifier = modifier - .then(placementModifier) - .zIndex(if (isDragging) 1f else 0f) - .graphicsLayer { translationY = if (isDragging) dragDropState.dragOffset else 0f }, - ) { - val heightModifier = Modifier.onSizeChanged { - dragDropState.onSizeChanged(index, it.height) - } - content(isDragging, heightModifier) - } -} diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 8d1f253eba..a2d6e39e5c 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1816,6 +1816,7 @@ Group constraints New constraint Delete group constraint + Invert constraint This group From 6fe5474d308357fb7629f36229cdc69649caa69c Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 14:39:43 +0200 Subject: [PATCH 072/101] #752 allow switching NOT state of key map group constraints --- .../base/constraints/ConstraintGroupItem.kt | 90 +++++-------------- .../base/groups/GroupConstraintRow.kt | 3 + .../base/home/HomeKeyMapListScreen.kt | 1 + .../keymapper/base/home/KeyMapListHeader.kt | 4 + .../base/home/KeyMapListViewModel.kt | 6 ++ .../keymapper/base/home/ListKeyMapsUseCase.kt | 29 ++++++ 6 files changed, 63 insertions(+), 70 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt index a099b3b422..2c72ecc45a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt @@ -12,9 +12,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkOut import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -42,7 +39,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.LookaheadScope import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -54,11 +50,9 @@ 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.DraggableColumnItem 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.compose.rememberColumnDragDropState /** * The description and error fade out in the first half of the animation so they are invisible @@ -118,7 +112,6 @@ fun ConstraintGroupItem( onRemoveConstraintClick: (String) -> Unit = {}, onFixConstraintClick: (String) -> Unit = {}, onNotClick: (String) -> Unit = {}, - onMoveConstraint: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }, onMoveUp: (() -> Unit)? = null, onMoveDown: (() -> Unit)? = null, ) { @@ -185,7 +178,6 @@ fun ConstraintGroupItem( onRemoveConstraintClick = onRemoveConstraintClick, onFixConstraintClick = onFixConstraintClick, onNotClick = onNotClick, - onMoveConstraint = onMoveConstraint, ) }, ) @@ -255,7 +247,6 @@ private fun ExpandedContent( onRemoveConstraintClick: (String) -> Unit, onFixConstraintClick: (String) -> Unit, onNotClick: (String) -> Unit, - onMoveConstraint: (fromIndex: Int, toIndex: Int) -> Unit, ) { Column(modifier) { Text( @@ -282,7 +273,6 @@ private fun ExpandedContent( onRemoveConstraintClick = onRemoveConstraintClick, onFixConstraintClick = onFixConstraintClick, onNotClick = onNotClick, - onMoveConstraint = onMoveConstraint, ) Spacer(Modifier.size(8.dp)) @@ -341,10 +331,6 @@ private fun ExpandedContent( } } -/** - * The constraints in a group. These are not in a lazy list so they are reordered by swapping - * the dragged constraint with its neighbour once it has been dragged over half of its height. - */ @Composable private fun GroupConstraintList( modifier: Modifier = Modifier, @@ -353,69 +339,33 @@ private fun GroupConstraintList( onRemoveConstraintClick: (String) -> Unit, onFixConstraintClick: (String) -> Unit, onNotClick: (String) -> Unit, - onMoveConstraint: (fromIndex: Int, toIndex: Int) -> Unit, ) { - val dragDropState = rememberColumnDragDropState( - itemCount = constraints.size, - onMove = onMoveConstraint, - ) - val linkText = when (mode) { ConstraintMode.AND -> stringResource(R.string.constraint_mode_and) ConstraintMode.OR -> stringResource(R.string.constraint_mode_or) } - LookaheadScope { - Column(modifier = modifier) { - constraints.forEachIndexed { index, constraint -> - key(constraint.id) { - val draggableState = rememberDraggableState(dragDropState::onDrag) - - DraggableColumnItem( - dragDropState = dragDropState, - index = index, - ) { isDragging, heightModifier -> - ConstraintListItem( - modifier = Modifier - .fillMaxWidth() - .then(heightModifier), - model = constraint, - isReorderingEnabled = constraints.size > 1, - isDragging = isDragging, - dragHandleModifier = Modifier.draggable( - state = draggableState, - orientation = Orientation.Vertical, - startDragImmediately = true, - onDragStarted = { dragDropState.onDragStarted(index) }, - onDragStopped = { dragDropState.onDragStopped() }, - ), - onRemoveClick = { onRemoveConstraintClick(constraint.id) }, - onFixClick = { onFixConstraintClick(constraint.id) }, - onNotClick = { onNotClick(constraint.id) }, - onMoveUp = if (index > 0) { - { onMoveConstraint(index, index - 1) } - } else { - null - }, - onMoveDown = if (index < constraints.lastIndex) { - { onMoveConstraint(index, index + 1) } - } else { - null - }, - ) + 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, - ) - } - } + 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, + ) } } } 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 dba8a92f6c..02d821aae3 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 @@ -51,6 +51,7 @@ fun GroupConstraintRow( parentConstraintCount: Int, onNewConstraintClick: () -> Unit = {}, onRemoveConstraintClick: (String) -> Unit = {}, + onNotConstraintClick: (String) -> Unit = {}, onFixConstraintClick: (KMError) -> Unit = {}, enabled: Boolean = true, ) { @@ -72,6 +73,7 @@ fun GroupConstraintRow( modifier = Modifier.widthIn(max = maxChipWidth), text = constraint.text, onRemoveClick = { onRemoveConstraintClick(constraint.id) }, + onNotClick = { onNotConstraintClick(constraint.id) }, icon = { if (constraint.icon is ComposeIconInfo.Vector) { Icon( @@ -106,6 +108,7 @@ fun GroupConstraintRow( text = constraint.text, onClick = { onFixConstraintClick(constraint.error) }, onRemoveClick = { onRemoveConstraintClick(constraint.id) }, + onNotClick = { onNotConstraintClick(constraint.id) }, // Only allow clicking on error chips enabled = enabled, ) 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 47a0e11712..1ad57475a1 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 @@ -202,6 +202,7 @@ fun HomeKeyMapListScreen( onGroupClick = viewModel::onGroupClick, onNewConstraintClick = viewModel::onNewGroupConstraintClick, onRemoveConstraintClick = viewModel::onRemoveGroupConstraintClick, + onNotConstraintClick = viewModel::onNotGroupConstraintClick, onConstraintModeChanged = viewModel::onGroupConstraintModeChanged, onFixConstraintClick = viewModel::onFixClick, onKeyMapsEnabledChange = viewModel::onGroupKeyMapsEnabledChanged, 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 5e30daec50..abe665b664 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 @@ -65,6 +65,7 @@ fun KeyMapListHeader( onGroupClick: (String?) -> Unit = {}, onNewConstraintClick: () -> Unit = {}, onRemoveConstraintClick: (String) -> Unit = {}, + onNotConstraintClick: (String) -> Unit = {}, onConstraintModeChanged: (ConstraintMode) -> Unit = {}, onFixConstraintClick: (KMError) -> Unit = {}, onKeyMapsEnabledChange: (Boolean) -> Unit = {}, @@ -109,6 +110,7 @@ fun KeyMapListHeader( onGroupClick = onGroupClick, onNewConstraintClick = onNewConstraintClick, onRemoveConstraintClick = onRemoveConstraintClick, + onNotConstraintClick = onNotConstraintClick, onConstraintModeChanged = onConstraintModeChanged, onFixConstraintClick = onFixConstraintClick, onKeyMapsEnabledChange = onKeyMapsEnabledChange, @@ -164,6 +166,7 @@ private fun ChildGroupHeader( onGroupClick: (String?) -> Unit, onNewConstraintClick: () -> Unit, onRemoveConstraintClick: (String) -> Unit, + onNotConstraintClick: (String) -> Unit, onConstraintModeChanged: (ConstraintMode) -> Unit, onFixConstraintClick: (KMError) -> Unit, onKeyMapsEnabledChange: (Boolean) -> Unit, @@ -188,6 +191,7 @@ private fun ChildGroupHeader( onFixConstraintClick = onFixConstraintClick, onNewConstraintClick = onNewConstraintClick, onRemoveConstraintClick = onRemoveConstraintClick, + onNotConstraintClick = onNotConstraintClick, enabled = enabled, ) 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 18390ac340..5a7b253e6d 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 @@ -914,6 +914,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 c62cc3f44f..cfb20d2c4a 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 @@ -367,6 +367,34 @@ class ListKeyMapsUseCaseImpl @Inject constructor( } } + 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)) + } catch (_: SQLiteConstraintException) { + return + } + } + } + override fun moveKeyMapsToGroup(groupUid: String?, vararg keyMapUids: String) { keyMapRepository.moveToGroup(groupUid, *keyMapUids) } @@ -451,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() From e1d61c1c5d3133fc5d0f94281b5a78ab2b3536ed Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 14:45:22 +0200 Subject: [PATCH 073/101] do not show whats new app bar button in group screens --- .../keymapper/base/home/KeyMapListAppBar.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) 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 6e4745b152..1ef479338a 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 @@ -135,6 +135,7 @@ fun KeyMapListAppBar( var expandedDropdown by rememberSaveable { mutableStateOf(false) } AppBarActions( + showWhatsNew = true, onWhatsNewClick, onMenuClick = { expandedDropdown = true }, dropdownMenuContent = { @@ -246,6 +247,7 @@ fun KeyMapListAppBar( var expandedDropdown by rememberSaveable { mutableStateOf(false) } AppBarActions( + showWhatsNew = false, onWhatsNewClick, onMenuClick = { expandedDropdown = true }, dropdownMenuContent = { @@ -410,16 +412,19 @@ private fun SelectingAppBar( @Composable private fun AppBarActions( + showWhatsNew: Boolean, onWhatsNewClick: () -> Unit, onMenuClick: () -> Unit = {}, dropdownMenuContent: @Composable () -> Unit, ) { Row { - IconButton(onClick = onWhatsNewClick) { - Icon( - Icons.Outlined.NewReleases, - contentDescription = stringResource(R.string.home_app_bar_whats_new), - ) + if (showWhatsNew) { + IconButton(onClick = onWhatsNewClick) { + Icon( + Icons.Outlined.NewReleases, + contentDescription = stringResource(R.string.home_app_bar_whats_new), + ) + } } IconButton(onClick = onMenuClick) { From 15ae627779015b557595e2af46fb954f326e14a1 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 16:01:53 +0200 Subject: [PATCH 074/101] reduce padding on button in slider option --- .../keymapper/base/utils/ui/compose/SliderOptionText.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 9fdfd8433e..ef39f2bfd1 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 @@ -109,7 +110,10 @@ fun SliderOptionText( 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)) From 500edb9fffa5ebae3c6364563317e27cbe7319bf Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 16:17:24 +0200 Subject: [PATCH 075/101] only emit value change from SliderOptionText when it is stopped dragging --- .../base/utils/ui/compose/SliderOptionText.kt | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) 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 ef39f2bfd1..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 @@ -56,22 +56,28 @@ 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 }, ) @@ -95,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, @@ -104,7 +115,7 @@ fun SliderOptionText( KeyMapperSliderThumb(interactionSource) }, steps = - (((valueRange.endInclusive - valueRange.start) / stepSize.toFloat()).toInt()) - + (((valueRange.endInclusive - valueRange.start) / stepSize.toFloat()).roundToInt()) - 1, ) @@ -115,10 +126,10 @@ fun SliderOptionText( 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) @@ -126,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( From d260879831d6d5934d0fa734fc9b4c68f3d51652 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 16:30:47 +0200 Subject: [PATCH 076/101] #982 feat: create vibrate action and select predefined vibration modes for triggers as well --- CHANGELOG.md | 2 + .../trigger/ConfigTriggerViewModel.kt | 3 + .../keymapper/base/BaseViewModelHiltModule.kt | 6 + .../keymapper/base/actions/ActionData.kt | 6 + .../base/actions/ActionDataEntityMapper.kt | 54 ++++++ .../sds100/keymapper/base/actions/ActionId.kt | 1 + .../keymapper/base/actions/ActionUiHelper.kt | 20 +++ .../keymapper/base/actions/ActionUtils.kt | 6 + .../base/actions/ChooseActionScreen.kt | 1 + .../base/actions/ChooseActionViewModel.kt | 4 +- .../base/actions/ConfigActionsViewModel.kt | 4 +- .../base/actions/CreateActionDelegate.kt | 19 ++- .../base/actions/PerformActionsUseCase.kt | 8 + .../base/actions/VibrateActionBottomSheet.kt | 156 +++++++++++++++++ .../base/detection/DetectKeyMapsUseCase.kt | 7 + .../base/detection/KeyMapAlgorithm.kt | 50 +++--- .../base/detection/SimpleMappingController.kt | 4 +- .../keymaps/ConfigKeyMapOptionsViewModel.kt | 23 ++- .../sds100/keymapper/base/keymaps/KeyMap.kt | 5 +- .../base/keymaps/KeyMapOptionsScreen.kt | 33 ++-- .../keymaps/TriggerVibrationBottomSheet.kt | 156 +++++++++++++++++ .../trigger/BaseConfigTriggerViewModel.kt | 3 + .../base/trigger/ConfigTriggerDelegate.kt | 18 +- .../base/trigger/ConfigTriggerUseCase.kt | 7 +- .../sds100/keymapper/base/trigger/Trigger.kt | 94 +++++++++-- .../base/utils/VibrateEffectStrings.kt | 13 ++ .../ui/compose/KeyMapperSegmentedButtonRow.kt | 15 +- .../SwitchSettingsPreferenceCompose.kt | 87 ++++++++++ .../compose/VibrationEffectPickerContent.kt | 158 ++++++++++++++++++ .../base/vibration/VibrateConfigDelegate.kt | 121 ++++++++++++++ .../keymapper/base/vibration/VibrateEffect.kt | 20 +++ .../base/vibration/VibratorAdapterExt.kt | 28 ++++ base/src/main/res/values/strings.xml | 10 ++ .../base/actions/PerformActionsUseCaseTest.kt | 1 + .../actions/VibrateActionEntityMapperTest.kt | 29 ++++ .../base/keymaps/KeyMapAlgorithmTest.kt | 36 +++- .../base/trigger/ConfigTriggerDelegateTest.kt | 44 +++++ .../base/trigger/TriggerEntityMapperTest.kt | 66 ++++++++ .../keymapper/data/entities/ActionEntity.kt | 6 + .../keymapper/data/entities/TriggerEntity.kt | 5 + .../system/vibrator/AndroidVibratorAdapter.kt | 61 +++++-- .../vibrator/PredefinedVibrationEffect.kt | 8 + .../system/vibrator/VibratorAdapter.kt | 7 + 43 files changed, 1306 insertions(+), 99 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/VibrateActionBottomSheet.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerVibrationBottomSheet.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/utils/VibrateEffectStrings.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchSettingsPreferenceCompose.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/VibrationEffectPickerContent.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/vibration/VibrateConfigDelegate.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/vibration/VibrateEffect.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/vibration/VibratorAdapterExt.kt create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/actions/VibrateActionEntityMapperTest.kt create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/trigger/TriggerEntityMapperTest.kt create mode 100644 system/src/main/java/io/github/sds100/keymapper/system/vibrator/PredefinedVibrationEffect.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 01108a056d..37d408893b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ - #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. ## Fixed 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/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/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index 0edf3eafb2..e268c7bba0 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 @@ -985,6 +986,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 08b3ee195e..4820983d24 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,6 +74,8 @@ object ActionDataEntityMapper { ActionEntity.Type.CREATE_NOTIFICATION -> ActionId.CREATE_NOTIFICATION ActionEntity.Type.TOAST -> ActionId.TOAST + + ActionEntity.Type.VIBRATE -> ActionId.VIBRATE } return when (actionId) { @@ -725,6 +728,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 @@ -998,6 +1034,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 } @@ -1087,6 +1124,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]!! @@ -1438,6 +1477,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), ) 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 dc034e2442..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 @@ -152,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/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index e97033d141..7ca683fe8a 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 @@ -829,6 +831,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) 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 a22841a24f..b7194a26d1 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 @@ -245,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 @@ -501,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 @@ -1101,6 +1105,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 @@ -1163,6 +1168,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/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/ConfigActionsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt index 474c9bc941..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 @@ -21,6 +21,7 @@ import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.ViewModelHelper import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.base.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,6 +54,7 @@ class ConfigActionsViewModel @Inject constructor( setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, fixKeyEventActionDelegate: FixKeyEventActionDelegate, private val onboardingTipDelegate: OnboardingTipDelegate, + private val vibrateConfigDelegate: VibrateConfigDelegate, resourceProvider: ResourceProvider, navigationProvider: NavigationProvider, dialogProvider: 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) 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 b83813530f..3c4ba4df9c 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) @@ -1238,6 +1249,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/PerformActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt index 3beebc8f1e..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 @@ -68,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 @@ -120,6 +122,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( private val inputEventHub: InputEventHub, private val systemBridgeConnectionManager: SystemBridgeConnectionManager, private val settingsAdapter: SettingsAdapter, + private val vibratorAdapter: VibratorAdapter, ) : PerformActionsUseCase { companion object { @@ -1027,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/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/detection/DetectKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt index 9b18691dda..f908f05a21 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 @@ -181,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, @@ -267,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..8b348e60a2 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 @@ -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/keymaps/ConfigKeyMapOptionsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt index 7cb08841e3..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 @@ -16,6 +16,8 @@ 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.TintType +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,9 +42,11 @@ 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) @@ -69,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) { @@ -196,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(), @@ -236,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, 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 539079d242..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 @@ -13,6 +13,7 @@ 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 @@ -35,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) 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 2b0a81479c..85246bc126 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 @@ -51,6 +51,7 @@ 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.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 @@ -79,6 +80,8 @@ fun KeyMapOptionsScreen(modifier: Modifier = Modifier, viewModel: ConfigKeyMapOp onDismissRequest = viewModel::onDismissCreateShortcutDialog, ) } + + TriggerVibrationBottomSheet(viewModel) } @Composable @@ -157,29 +160,15 @@ private fun Loaded( ) if (state.showVibrate) { - SwitchPreferenceCompose( + 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(), - 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, + isSettingsEnabled = state.showVibrateSettings, + onSettingsClick = callback::onVibrateSettingsClick, ) } @@ -350,7 +339,7 @@ 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 { } @@ -380,8 +369,8 @@ private fun Preview() { sequenceTriggerTimeout = 1000, defaultSequenceTriggerTimeout = 1000, - showVibrateDuration = true, - vibrateDuration = 100, + showVibrateSettings = true, + vibrateEffect = null, defaultVibrateDuration = 100, showVibrate = true, @@ -424,8 +413,8 @@ private fun PreviewEvdevTrigger() { sequenceTriggerTimeout = 1000, defaultSequenceTriggerTimeout = 1000, - showVibrateDuration = false, - vibrateDuration = 100, + showVibrateSettings = false, + vibrateEffect = null, defaultVibrateDuration = 100, showVibrate = true, 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/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/ConfigTriggerDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegate.kt index 2bb4e4db21..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 { 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 d0164dc3e6..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 -> @@ -327,7 +328,7 @@ 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) 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 232c382840..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,7 +25,7 @@ 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 showToast: Boolean = false, @@ -89,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 } @@ -126,8 +132,7 @@ 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, @@ -173,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) { @@ -212,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) } } @@ -228,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/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/ui/compose/KeyMapperSegmentedButtonRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperSegmentedButtonRow.kt index 3695568420..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 @@ -28,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 @@ -38,9 +40,12 @@ fun KeyMapperSegmentedButtonRow( onStateSelected: (T) -> Unit, isCompact: Boolean = false, isEnabled: Boolean = true, + isStateEnabled: (T) -> Boolean = { true }, colors: SegmentedButtonColors = SegmentedButtonDefaults.colors(), ) { - val colors = if (isEnabled) { + 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 @@ -56,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), @@ -91,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/SwitchSettingsPreferenceCompose.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchSettingsPreferenceCompose.kt new file mode 100644 index 0000000000..65783344ae --- /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 = isEnabled && 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/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/strings.xml b/base/src/main/res/values/strings.xml index a2d6e39e5c..6efe84ff9f 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1259,6 +1259,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 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 f4d94c9cc9..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 @@ -91,6 +91,7 @@ class PerformActionsUseCaseTest { coroutineScope = testCoroutineScope, notificationAdapter = mock(), settingsAdapter = mock(), + vibratorAdapter = mock(), ) } 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/keymaps/KeyMapAlgorithmTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt index 73b32697bb..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 @@ -32,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 @@ -951,7 +952,10 @@ class KeyMapAlgorithmTest { advanceUntilIdle() // THEN - verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION) + verify( + detectKeyMapsUseCase, + times(1), + ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION)) } /** @@ -991,7 +995,10 @@ class KeyMapAlgorithmTest { advanceUntilIdle() // THEN - verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION) + verify( + detectKeyMapsUseCase, + times(1), + ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION)) } /** @@ -1030,7 +1037,10 @@ class KeyMapAlgorithmTest { advanceUntilIdle() // THEN - verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION) + verify( + detectKeyMapsUseCase, + times(1), + ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION)) } /** @@ -1071,7 +1081,10 @@ class KeyMapAlgorithmTest { advanceUntilIdle() // THEN - verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION) + verify( + detectKeyMapsUseCase, + times(1), + ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION)) } /** @@ -1112,7 +1125,10 @@ class KeyMapAlgorithmTest { advanceUntilIdle() // THEN - verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION) + verify( + detectKeyMapsUseCase, + times(1), + ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION)) } /** @@ -1153,7 +1169,10 @@ class KeyMapAlgorithmTest { advanceUntilIdle() // THEN - verify(detectKeyMapsUseCase, times(2)).vibrate(VIBRATION_DURATION) + verify( + detectKeyMapsUseCase, + times(2), + ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION)) } /** @@ -1192,7 +1211,10 @@ class KeyMapAlgorithmTest { advanceUntilIdle() // THEN - verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION) + verify( + detectKeyMapsUseCase, + times(1), + ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION)) } @Test 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/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 2ec96727b1..2687b63959 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 @@ -99,6 +99,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" @@ -217,6 +222,7 @@ data class ActionEntity( MODIFY_SETTING, CREATE_NOTIFICATION, TOAST, + VIBRATE, } constructor( 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 c4ebb5d964..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 @@ -54,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/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) } From a88e196426b1872a800e42d056c3d94b0e0eb9b6 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 20:25:13 +0200 Subject: [PATCH 077/101] change icon for Enable/disable by intent button --- .../base/keymaps/KeyMapOptionsScreen.kt | 4 +- .../base/utils/ui/compose/icons/Switch.kt | 83 ------------------- 2 files changed, 2 insertions(+), 85 deletions(-) delete mode 100644 base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/Switch.kt 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 85246bc126..01c70cacb9 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 @@ -23,6 +23,7 @@ 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.material.icons.rounded.PowerSettingsNew import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -55,7 +56,6 @@ import io.github.sds100.keymapper.base.utils.ui.compose.SwitchSettingsPreference 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.base.utils.ui.compose.icons.Switch import io.github.sds100.keymapper.common.utils.State import kotlinx.coroutines.launch @@ -262,7 +262,7 @@ private fun Loaded( modifier = Modifier.fillMaxWidth(), title = stringResource(R.string.key_map_options_enable_by_intent_title), text = stringResource(R.string.key_map_options_intent_summary), - icon = KeyMapperIcons.Switch, + icon = Icons.Rounded.PowerSettingsNew, onClick = callback::onEnableByIntentClick, ) OptionPageButton( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/Switch.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/Switch.kt deleted file mode 100644 index 45906379dc..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/Switch.kt +++ /dev/null @@ -1,83 +0,0 @@ -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.Switch: ImageVector - get() { - if (_Switch != null) { - return _Switch!! - } - _Switch = ImageVector.Builder( - name = "Switch", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ).apply { - path(fill = SolidColor(Color.Black)) { - moveTo(240f, 880f) - quadToRelative(-33f, 0f, -56.5f, -23.5f) - reflectiveQuadTo(160f, 800f) - verticalLineToRelative(-640f) - quadToRelative(0f, -33f, 23.5f, -56.5f) - reflectiveQuadTo(240f, 80f) - horizontalLineToRelative(480f) - quadToRelative(33f, 0f, 56.5f, 23.5f) - reflectiveQuadTo(800f, 160f) - verticalLineToRelative(640f) - quadToRelative(0f, 33f, -23.5f, 56.5f) - reflectiveQuadTo(720f, 880f) - lineTo(240f, 880f) - close() - moveTo(240f, 800f) - horizontalLineToRelative(480f) - verticalLineToRelative(-640f) - lineTo(240f, 160f) - verticalLineToRelative(640f) - close() - moveTo(320f, 680f) - horizontalLineToRelative(320f) - verticalLineToRelative(-400f) - lineTo(320f, 280f) - verticalLineToRelative(400f) - close() - moveTo(400f, 600f) - verticalLineToRelative(-160f) - horizontalLineToRelative(160f) - verticalLineToRelative(160f) - lineTo(400f, 600f) - close() - moveTo(480f, 250f) - quadToRelative(13f, 0f, 21.5f, -8.5f) - reflectiveQuadTo(510f, 220f) - quadToRelative(0f, -13f, -8.5f, -21.5f) - reflectiveQuadTo(480f, 190f) - quadToRelative(-13f, 0f, -21.5f, 8.5f) - reflectiveQuadTo(450f, 220f) - quadToRelative(0f, 13f, 8.5f, 21.5f) - reflectiveQuadTo(480f, 250f) - close() - moveTo(480f, 770f) - quadToRelative(13f, 0f, 21.5f, -8.5f) - reflectiveQuadTo(510f, 740f) - quadToRelative(0f, -13f, -8.5f, -21.5f) - reflectiveQuadTo(480f, 710f) - quadToRelative(-13f, 0f, -21.5f, 8.5f) - reflectiveQuadTo(450f, 740f) - quadToRelative(0f, 13f, 8.5f, 21.5f) - reflectiveQuadTo(480f, 770f) - close() - moveTo(480f, 480f) - close() - } - }.build() - - return _Switch!! - } - -@Suppress("ObjectPropertyName") -private var _Switch: ImageVector? = null From 04bf92fd2c5b650c6564b655e2586f8257cd8911 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 20:59:52 +0200 Subject: [PATCH 078/101] use more sensible names for NOT versions of phone ringing constraints --- .../sds100/keymapper/base/constraints/ConstraintUiHelper.kt | 4 ++-- .../sds100/keymapper/base/constraints/ConstraintUtils.kt | 2 ++ base/src/main/res/values/strings.xml | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) 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 fd20e18d87..2d9d4c72b9 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 @@ -213,9 +213,9 @@ class ConstraintUiHelper( is ConstraintData.DeviceIsLocked -> getString(R.string.constraint_device_is_unlocked) - is ConstraintData.InPhoneCall -> getString(R.string.constraint_not_in_phone_call) + is ConstraintData.InPhoneCall -> getString(R.string.constraint_in_phone_call_not) - is ConstraintData.NotInPhoneCall -> getString(R.string.constraint_in_phone_call) + is ConstraintData.NotInPhoneCall -> getString(R.string.constraint_not_in_phone_call_not) is ConstraintData.LockScreenShowing -> getString(R.string.constraint_lock_screen_not_showing) 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 01512a07be..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 @@ -226,6 +226,8 @@ object ConstraintUtils { 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) diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 6efe84ff9f..e80f66bbd5 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -322,9 +322,11 @@ Lock screen is not showing In phone call + Ringing or not in call Not in phone call + Ringing or in call Phone ringing - Phone not ringing + Not ringing Ringer mode: Ring Ringer mode: Vibrate Ringer mode: Silent From 68b6fc5a75b15c61405c8146a767d0cdc31c76e9 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 21:14:22 +0200 Subject: [PATCH 079/101] #2167 fix: do not show errors for app not in foreground constraint when the app is uninstalled/disabled --- CHANGELOG.md | 1 + app/version.properties | 2 +- .../constraints/ConstraintErrorSnapshot.kt | 21 ++-- .../ConstraintErrorSnapshotTest.kt | 103 ++++++++++++++++++ 4 files changed, 117 insertions(+), 10 deletions(-) create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshotTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 37d408893b..e710706e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - 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. ## [4.4.1](https://github.com/sds100/KeyMapper/releases/tag/v4.4.1) diff --git a/app/version.properties b/app/version.properties index 2a017b1a03..b1631e701c 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ VERSION_NAME=4.5.0 -VERSION_CODE=266 +VERSION_CODE=268 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 416277966c..8aaacf54ec 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,14 +36,15 @@ class LazyConstraintErrorSnapshot( override fun getError(constraint: Constraint): KMError? { when (constraint.data) { - is ConstraintData.AppInForeground -> return getAppError(constraint.data.packageName) + is ConstraintData.AppInForeground -> + return getAppError(constraint.data.packageName, 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.isNot) } ConstraintData.MediaPlaying -> { @@ -72,10 +72,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 @@ -111,7 +107,7 @@ class LazyConstraintErrorSnapshot( } if (constraint.data is ConstraintData.NotificationPosted.FromApp) { - return getAppError(constraint.data.packageName) + return getAppError(constraint.data.packageName, constraint.isNot) } } @@ -121,7 +117,14 @@ class LazyConstraintErrorSnapshot( return null } - private fun getAppError(packageName: String): KMError? { + private fun getAppError(packageName: 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) 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..caa4253a6a --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshotTest.kt @@ -0,0 +1,103 @@ +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?), + ) + } +} From 08ada4e546d46dd6ee071a4185d3e89e41f658f7 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 21:20:48 +0200 Subject: [PATCH 080/101] move 4.4.1 changelog to 4.5.0 --- CHANGELOG.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e710706e6a..c4bc577369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - #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 @@ -32,14 +33,6 @@ - #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. -## [4.4.1](https://github.com/sds100/KeyMapper/releases/tag/v4.4.1) - -#### TO BE RELEASED - -## Added - -- #2243 Redesigned What's New dialog. - ## [4.4.0](https://github.com/sds100/KeyMapper/releases/tag/v4.4.0) #### 12 September 2026 From 00c50f328045f17e51eff1f1bc2cb44f0261272b Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 22:31:36 +0200 Subject: [PATCH 081/101] #2249 feat: use simple Fix button on key map list item instead of error trigger, action, constraint chips --- .../keymapper/base/home/FixErrorsDialog.kt | 147 ++++++ .../base/home/HomeKeyMapListScreen.kt | 17 +- .../sds100/keymapper/base/home/KeyMapError.kt | 25 ++ .../keymapper/base/home/KeyMapListItem.kt | 420 ++++++++++++++++++ .../base/home/KeyMapListItemCreator.kt | 14 - .../keymapper/base/home/KeyMapListScreen.kt | 360 +-------------- .../base/home/KeyMapListViewModel.kt | 83 +++- .../shortcuts/CreateKeyMapShortcutScreen.kt | 3 - .../CreateKeyMapShortcutViewModel.kt | 6 - .../base/trigger/KeyMapListItemModel.kt | 1 - .../sds100/keymapper/base/utils/ErrorUtils.kt | 36 ++ .../base/utils/ui/compose/CompactChip.kt | 3 +- base/src/main/res/values/strings.xml | 2 +- 13 files changed, 722 insertions(+), 395 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/home/FixErrorsDialog.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapError.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt 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..da1dac89d3 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/FixErrorsDialog.kt @@ -0,0 +1,147 @@ +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) + +@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 1ad57475a1..2e6ca27d0e 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 @@ -136,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) { @@ -214,7 +223,6 @@ fun HomeKeyMapListScreen( onLongClickKeyMap = viewModel::onKeyMapCardLongClick, onSelectedChange = viewModel::onKeyMapSelectedChanged, onFixClick = viewModel::onFixClick, - onTriggerErrorClick = viewModel::onFixTriggerError, bottomListPadding = keyMapListBottomPadding, ) }, @@ -500,7 +508,6 @@ fun sameKeyMapListItems(): List { ), ), options = listOf("Vibrate"), - triggerErrors = listOf(TriggerError.DND_ACCESS_DENIED), isEnabled = true, ), ), @@ -533,7 +540,6 @@ fun sameKeyMapListItems(): List { "Vibrate", "Vibrate when keys are initially pressed and again when long pressed", ), - triggerErrors = emptyList(), isEnabled = true, ), ), @@ -565,7 +571,6 @@ fun sameKeyMapListItems(): List { ), ), options = emptyList(), - triggerErrors = emptyList(), isEnabled = false, ), ), @@ -587,7 +592,6 @@ fun sameKeyMapListItems(): List { constraintMode = ConstraintMode.AND, constraints = emptyList(), options = emptyList(), - triggerErrors = emptyList(), isEnabled = true, ), ), @@ -601,7 +605,6 @@ fun sameKeyMapListItems(): List { constraintMode = ConstraintMode.OR, constraints = emptyList(), options = emptyList(), - triggerErrors = emptyList(), isEnabled = true, ), ), 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/KeyMapListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt new file mode 100644 index 0000000000..d0668c467f --- /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) { + 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, + model.text, + 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 559f6312ec..ea8e16f814 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 @@ -22,7 +22,6 @@ import io.github.sds100.keymapper.base.trigger.KeyEventTriggerDevice 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 @@ -49,7 +48,6 @@ class KeyMapListItemCreator( fun build( keyMap: KeyMap, showDeviceDescriptors: Boolean, - triggerErrorSnapshot: TriggerErrorSnapshot, actionErrorSnapshot: ActionErrorSnapshot, constraintErrorSnapshot: ConstraintErrorSnapshot, ): KeyMapListItemModel.Content { @@ -86,22 +84,10 @@ class KeyMapListItemCreator( isEnabled = keyMap.isEnabled, ) - val triggerErrors = if (keyMap.isEnabled) { - keyMap.trigger.keys.mapNotNull { key -> - triggerErrorSnapshot.getTriggerError( - keyMap, - key, - ) - }.distinct() - } else { - emptyList() - } - return KeyMapListItemModel.Content( uid = keyMap.uid, triggerKeys = triggerKeys, triggerSeparatorIcon = triggerSeparator, - triggerErrors = triggerErrors, actions = actionChipList, constraints = constraintChipList, constraintMode = keyMap.constraintState.mode, 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 aa45ade8ea..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,72 +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.material3.CardDefaults -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.draw.alpha -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.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.common.utils.KMError import io.github.sds100.keymapper.common.utils.State @Composable @@ -80,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 @@ -166,8 +136,7 @@ fun KeyMapList( onLongClickKeyMap(model.content.uid) }, onSelectedChange = { onSelectedChange(model.content.uid, it) }, - onFixClick = onFixClick, - onTriggerErrorClick = onTriggerErrorClick, + onFixClick = { onFixClick(model.content.uid) }, ) } @@ -228,331 +197,6 @@ 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, -) { - 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), - 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(" ") - - 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: (KMError) -> 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(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, - ) - } -} - @Preview @Composable private fun ListPreview() { 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 5a7b253e6d..49d769cfca 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 @@ -29,6 +29,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 +144,36 @@ class KeyMapListViewModel( ), ) + private val fixErrorsKeyMapUid = MutableStateFlow(null) + + val fixErrorsDialogState: StateFlow = + fixErrorsKeyMapUid.flatMapLatest { uid -> + if (uid == null) { + flowOf(null) + } else { + combine( + keyMapGroupStateFlow, + listKeyMaps.triggerErrorSnapshot, + listKeyMaps.actionErrorSnapshot, + listKeyMaps.constraintErrorSnapshot, + ) { keyMapGroup, triggerSnapshot, actionSnapshot, constraintSnapshot -> + val keyMap = keyMapGroup.keyMaps.dataOrNull()?.firstOrNull { it.uid == uid } + val errors = 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() @@ -246,7 +277,6 @@ class KeyMapListViewModel( combine( sortedKeyMapsFlow, listKeyMaps.showDeviceDescriptors, - listKeyMaps.triggerErrorSnapshot, listKeyMaps.actionErrorSnapshot, listKeyMaps.constraintErrorSnapshot, transform = ::buildListItems, @@ -486,7 +516,6 @@ class KeyMapListViewModel( private fun buildListItems( keyMapsState: State>, showDeviceDescriptors: Boolean, - triggerErrorSnapshot: TriggerErrorSnapshot, actionErrorSnapshot: ActionErrorSnapshot, constraintErrorSnapshot: ConstraintErrorSnapshot, ): State> { @@ -495,7 +524,6 @@ class KeyMapListViewModel( listItemCreator.build( it, showDeviceDescriptors, - triggerErrorSnapshot, actionErrorSnapshot, constraintErrorSnapshot, ) @@ -503,6 +531,31 @@ 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() + } + fun onKeyMapCardClick(uid: String) { if (multiSelectProvider.state.value is SelectionState.Selecting) { multiSelectProvider.toggleSelection(uid) @@ -576,6 +629,30 @@ class KeyMapListViewModel( } } + fun onFixClick(keyMapUid: String) { + fixErrorsKeyMapUid.value = keyMapUid + } + + 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) { 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 b3c92ec347..724c1337cf 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,7 +307,6 @@ private fun keyMapSampleList(): List { ), ), options = listOf("Vibrate"), - triggerErrors = listOf(TriggerError.DND_ACCESS_DENIED), isEnabled = false, ), ), @@ -322,7 +320,6 @@ private fun keyMapSampleList(): List { constraintMode = ConstraintMode.OR, constraints = emptyList(), options = emptyList(), - triggerErrors = emptyList(), isEnabled = true, ), ), 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 bee979949f..b9fc346c9a 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 @@ -22,7 +22,6 @@ 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.KeyMapListItemModel -import io.github.sds100.keymapper.base.trigger.TriggerErrorSnapshot 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 @@ -76,20 +75,17 @@ class CreateKeyMapShortcutViewModel @Inject constructor( combine( listKeyMaps.keyMapGroup, listKeyMaps.showDeviceDescriptors, - listKeyMaps.triggerErrorSnapshot, listKeyMaps.actionErrorSnapshot, listKeyMaps.constraintErrorSnapshot, ) { keyMapGroup, showDeviceDescriptors, - triggerErrorSnapshot, actionErrorSnapshot, constraintErrorSnapshot, -> _state.value = buildState( keyMapGroup, showDeviceDescriptors, - triggerErrorSnapshot, actionErrorSnapshot, constraintErrorSnapshot, ) @@ -100,7 +96,6 @@ class CreateKeyMapShortcutViewModel @Inject constructor( private fun buildState( keyMapGroup: KeyMapGroup, showDeviceDescriptors: Boolean, - triggerErrorSnapshot: TriggerErrorSnapshot, actionErrorSnapshot: ActionErrorSnapshot, constraintErrorSnapshot: ConstraintErrorSnapshot, ): KeyMapListState { @@ -109,7 +104,6 @@ class CreateKeyMapShortcutViewModel @Inject constructor( val content = listItemCreator.build( it, showDeviceDescriptors, - triggerErrorSnapshot, actionErrorSnapshot, constraintErrorSnapshot, ) 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 1dc0a93000..e9ed35fd90 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,7 +10,6 @@ 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, 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..01edd5c534 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 @@ -612,3 +613,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/ui/compose/CompactChip.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt index 3eb294318b..a7fa0dfc1f 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 @@ -71,11 +71,10 @@ fun ErrorCompactChip(onClick: () -> Unit, text: String, enabled: Boolean) { 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, ) diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index e80f66bbd5..f6f07a6a1d 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -542,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. From b0e3cd2118e190cd2ca638f8104bb1ff8fdd9e64 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 22:48:40 +0200 Subject: [PATCH 082/101] fix: only show Fix button on key map if there are errors --- app/version.properties | 2 +- .../keymapper/base/home/HomeKeyMapListScreen.kt | 5 +++++ .../sds100/keymapper/base/home/KeyMapListItem.kt | 2 +- .../keymapper/base/home/KeyMapListItemCreator.kt | 11 +++++++++++ .../sds100/keymapper/base/home/KeyMapListViewModel.kt | 3 +++ .../base/shortcuts/CreateKeyMapShortcutScreen.kt | 2 ++ .../base/shortcuts/CreateKeyMapShortcutViewModel.kt | 8 ++++++++ .../keymapper/base/trigger/KeyMapListItemModel.kt | 1 + 8 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/version.properties b/app/version.properties index b1631e701c..3c584b5c89 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ VERSION_NAME=4.5.0 -VERSION_CODE=268 +VERSION_CODE=269 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 2e6ca27d0e..eed34ff9a5 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 @@ -509,6 +509,7 @@ fun sameKeyMapListItems(): List { ), options = listOf("Vibrate"), isEnabled = true, + hasError = true, ), ), KeyMapListItemModel( @@ -541,6 +542,7 @@ fun sameKeyMapListItems(): List { "Vibrate when keys are initially pressed and again when long pressed", ), isEnabled = true, + hasError = false, ), ), KeyMapListItemModel( @@ -572,6 +574,7 @@ fun sameKeyMapListItems(): List { ), options = emptyList(), isEnabled = false, + hasError = false, ), ), KeyMapListItemModel( @@ -593,6 +596,7 @@ fun sameKeyMapListItems(): List { constraints = emptyList(), options = emptyList(), isEnabled = true, + hasError = false, ), ), KeyMapListItemModel( @@ -606,6 +610,7 @@ fun sameKeyMapListItems(): List { constraints = emptyList(), options = emptyList(), isEnabled = true, + hasError = false, ), ), ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt index d0668c467f..4243fb2708 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt @@ -108,7 +108,7 @@ fun KeyMapListItem( separator = model.content.triggerSeparatorIcon, ) - if (model.content.isEnabled) { + if (model.content.isEnabled && model.content.hasError) { CompactErrorButton(onClick = onFixClick) { Text(stringResource(R.string.button_fix)) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt index ea8e16f814..f39a03e5a1 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 @@ -22,6 +22,7 @@ import io.github.sds100.keymapper.base.trigger.KeyEventTriggerDevice 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 @@ -48,6 +49,7 @@ class KeyMapListItemCreator( fun build( keyMap: KeyMap, showDeviceDescriptors: Boolean, + triggerErrorSnapshot: TriggerErrorSnapshot, actionErrorSnapshot: ActionErrorSnapshot, constraintErrorSnapshot: ConstraintErrorSnapshot, ): KeyMapListItemModel.Content { @@ -84,6 +86,14 @@ class KeyMapListItemCreator( isEnabled = keyMap.isEnabled, ) + val hasTriggerError = + keyMap.trigger.keys.any { triggerErrorSnapshot.getTriggerError(keyMap, it) != null } + + val hasError: Boolean = + hasTriggerError || + actionChipList.any { it is ComposeChipModel.Error } || + constraintChipList.any { it is ComposeChipModel.Error } + return KeyMapListItemModel.Content( uid = keyMap.uid, triggerKeys = triggerKeys, @@ -93,6 +103,7 @@ class KeyMapListItemCreator( constraintMode = keyMap.constraintState.mode, options = options, isEnabled = keyMap.isEnabled, + hasError = hasError, ) } 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 49d769cfca..2a5e18b081 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 @@ -277,6 +277,7 @@ class KeyMapListViewModel( combine( sortedKeyMapsFlow, listKeyMaps.showDeviceDescriptors, + listKeyMaps.triggerErrorSnapshot, listKeyMaps.actionErrorSnapshot, listKeyMaps.constraintErrorSnapshot, transform = ::buildListItems, @@ -516,6 +517,7 @@ class KeyMapListViewModel( private fun buildListItems( keyMapsState: State>, showDeviceDescriptors: Boolean, + triggerErrorSnapshot: TriggerErrorSnapshot, actionErrorSnapshot: ActionErrorSnapshot, constraintErrorSnapshot: ConstraintErrorSnapshot, ): State> { @@ -524,6 +526,7 @@ class KeyMapListViewModel( listItemCreator.build( it, showDeviceDescriptors, + triggerErrorSnapshot, actionErrorSnapshot, constraintErrorSnapshot, ) 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 724c1337cf..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 @@ -308,6 +308,7 @@ private fun keyMapSampleList(): List { ), options = listOf("Vibrate"), isEnabled = false, + hasError = true, ), ), KeyMapListItemModel( @@ -321,6 +322,7 @@ private fun keyMapSampleList(): List { constraints = emptyList(), options = emptyList(), isEnabled = true, + hasError = false, ), ), ) 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 b9fc346c9a..550a6dd58e 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 @@ -22,6 +22,7 @@ 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.KeyMapListItemModel +import io.github.sds100.keymapper.base.trigger.TriggerErrorSnapshot 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 @@ -75,17 +76,20 @@ class CreateKeyMapShortcutViewModel @Inject constructor( combine( listKeyMaps.keyMapGroup, listKeyMaps.showDeviceDescriptors, + listKeyMaps.triggerErrorSnapshot, listKeyMaps.actionErrorSnapshot, listKeyMaps.constraintErrorSnapshot, ) { keyMapGroup, showDeviceDescriptors, + triggerErrorSnapshot, actionErrorSnapshot, constraintErrorSnapshot, -> _state.value = buildState( keyMapGroup, showDeviceDescriptors, + triggerErrorSnapshot, actionErrorSnapshot, constraintErrorSnapshot, ) @@ -96,6 +100,7 @@ class CreateKeyMapShortcutViewModel @Inject constructor( private fun buildState( keyMapGroup: KeyMapGroup, showDeviceDescriptors: Boolean, + triggerErrorSnapshot: TriggerErrorSnapshot, actionErrorSnapshot: ActionErrorSnapshot, constraintErrorSnapshot: ConstraintErrorSnapshot, ): KeyMapListState { @@ -104,6 +109,7 @@ class CreateKeyMapShortcutViewModel @Inject constructor( val content = listItemCreator.build( it, showDeviceDescriptors, + triggerErrorSnapshot, actionErrorSnapshot, constraintErrorSnapshot, ) @@ -195,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/trigger/KeyMapListItemModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyMapListItemModel.kt index e9ed35fd90..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 @@ -16,5 +16,6 @@ data class KeyMapListItemModel(val isSelected: Boolean, val content: Content) { val constraints: List, val options: List, val isEnabled: Boolean, + val hasError: Boolean, ) } From 520fed39a7435bde8db55adb60757cc571f88ddf Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 22:56:59 +0200 Subject: [PATCH 083/101] always allow users to play with key map vibration settings. Never disable the option. --- .../sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt | 3 ++- .../base/utils/ui/compose/SwitchSettingsPreferenceCompose.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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 01c70cacb9..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 @@ -167,7 +167,8 @@ private fun Loaded( icon = Icons.Outlined.Vibration, isChecked = state.vibrate, onCheckedChange = callback::onVibrateChanged, - isSettingsEnabled = state.showVibrateSettings, + // Always allow users to play around with the vibration settings. + isSettingsEnabled = true, onSettingsClick = callback::onVibrateSettingsClick, ) } 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 index 65783344ae..6db29e607a 100644 --- 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 @@ -69,7 +69,7 @@ fun SwitchSettingsPreferenceCompose( IconButton( onClick = onSettingsClick, - enabled = isEnabled && isSettingsEnabled, + enabled = isSettingsEnabled, ) { Icon( imageVector = Icons.Outlined.Settings, From 3a19437d84ff8f7a342c768e98b153046cc3f7e5 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 22:58:02 +0200 Subject: [PATCH 084/101] use different icon for whats new app bar icon --- .../io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 1ef479338a..9be7a67ed5 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 @@ -30,7 +30,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Sort -import androidx.compose.material.icons.outlined.NewReleases +import androidx.compose.material.icons.outlined.Campaign import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Done @@ -421,7 +421,7 @@ private fun AppBarActions( if (showWhatsNew) { IconButton(onClick = onWhatsNewClick) { Icon( - Icons.Outlined.NewReleases, + Icons.Outlined.Campaign, contentDescription = stringResource(R.string.home_app_bar_whats_new), ) } From 6d29d096a78e6a0eb3ca234f8b2adf7b2965c22a Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 23:36:11 +0200 Subject: [PATCH 085/101] tap group constraints to invert them with a hint and add new fix button in group constraints --- .../base/groups/GroupConstraintRow.kt | 256 +++++------------- .../keymapper/base/home/FixErrorsDialog.kt | 5 + .../base/home/HomeKeyMapListScreen.kt | 2 +- .../keymapper/base/home/KeyMapListHeader.kt | 34 ++- .../keymapper/base/home/KeyMapListItem.kt | 4 +- .../base/home/KeyMapListViewModel.kt | 58 +++- .../base/utils/ui/compose/CompactChip.kt | 31 ++- base/src/main/res/values/strings.xml | 2 +- 8 files changed, 176 insertions(+), 216 deletions(-) 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 02d821aae3..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,20 +5,16 @@ 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 import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.SwapHorizontalCircle 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 @@ -31,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 @@ -52,11 +49,12 @@ fun GroupConstraintRow( onNewConstraintClick: () -> Unit = {}, onRemoveConstraintClick: (String) -> Unit = {}, onNotConstraintClick: (String) -> Unit = {}, - onFixConstraintClick: (KMError) -> 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), @@ -65,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) }, - onNotClick = { 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, - ) - } - }, - ) - } + 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) }, - onNotClick = { onNotConstraintClick(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) { @@ -191,142 +207,6 @@ private fun NewConstraintButton( } } -@Composable -private fun ConstraintButton( - modifier: Modifier = Modifier, - text: String, - icon: @Composable () -> Unit, - onRemoveClick: () -> Unit = {}, - onNotClick: () -> Unit = {}, -) { - 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 = onNotClick, - ) { - Icon( - imageVector = Icons.Outlined.SwapHorizontalCircle, - contentDescription = stringResource( - R.string.home_group_invert_constraint_button, - ), - ) - } - - Spacer(modifier = Modifier.width(4.dp)) - - IconButton( - modifier = Modifier.size(16.dp), - onClick = onRemoveClick, - ) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = stringResource( - R.string.home_group_delete_constraint_button, - ), - ) - } - } - } - } -} - -@Composable -private fun ConstraintErrorButton( - modifier: Modifier = Modifier, - text: String, - onClick: () -> Unit, - onRemoveClick: () -> Unit = {}, - enabled: Boolean, - onNotClick: () -> Unit = {}, -) { - 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 = onNotClick, - ) { - Icon( - imageVector = Icons.Outlined.SwapHorizontalCircle, - contentDescription = stringResource( - R.string.home_group_invert_constraint_button, - ), - ) - } - - Spacer(modifier = Modifier.width(4.dp)) - - IconButton( - modifier = Modifier.size(16.dp), - onClick = onRemoveClick, - ) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = stringResource( - R.string.home_group_delete_constraint_button, - ), - ) - } - } - } - } -} - @Preview @Composable private fun PreviewEmpty() { 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 index da1dac89d3..a73b71caf7 100644 --- 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 @@ -27,6 +27,11 @@ 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, 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 eed34ff9a5..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 @@ -213,7 +213,7 @@ fun HomeKeyMapListScreen( onRemoveConstraintClick = viewModel::onRemoveGroupConstraintClick, onNotConstraintClick = viewModel::onNotGroupConstraintClick, onConstraintModeChanged = viewModel::onGroupConstraintModeChanged, - onFixConstraintClick = viewModel::onFixClick, + onFixClick = viewModel::onFixGroupConstraintsClick, onKeyMapsEnabledChange = viewModel::onGroupKeyMapsEnabledChanged, ) }, 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 abe665b664..6438e4c679 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,7 +10,6 @@ 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 @@ -43,6 +42,7 @@ 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.drawable @@ -67,7 +67,7 @@ fun KeyMapListHeader( 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 @@ -112,7 +112,7 @@ fun KeyMapListHeader( onRemoveConstraintClick = onRemoveConstraintClick, onNotConstraintClick = onNotConstraintClick, onConstraintModeChanged = onConstraintModeChanged, - onFixConstraintClick = onFixConstraintClick, + onFixClick = onFixClick, onKeyMapsEnabledChange = onKeyMapsEnabledChange, ) @@ -168,7 +168,7 @@ private fun ChildGroupHeader( onRemoveConstraintClick: (String) -> Unit, onNotConstraintClick: (String) -> Unit, onConstraintModeChanged: (ConstraintMode) -> Unit, - onFixConstraintClick: (KMError) -> Unit, + onFixClick: () -> Unit, onKeyMapsEnabledChange: (Boolean) -> Unit, ) { val enabled = !state.isEditingGroupName @@ -180,7 +180,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) @@ -188,15 +208,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() @@ -209,6 +226,7 @@ private fun ChildGroupHeader( activeContentColor = MaterialTheme.colorScheme.onPrimary, activeBorderColor = MaterialTheme.colorScheme.onPrimaryContainer, inactiveBorderColor = MaterialTheme.colorScheme.onPrimaryContainer, + inactiveContentColor = MaterialTheme.colorScheme.onPrimaryContainer, ) AnimatedVisibility(visible = state.constraints.size > 1) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt index 4243fb2708..5475cecad3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt @@ -308,8 +308,8 @@ private fun ActionConstraintChip(model: ComposeChipModel, onFixClick: () -> Unit is ComposeChipModel.Error -> ErrorCompactChip( onClick = onFixClick, - model.text, - model.isFixable, + text = model.text, + enabled = model.isFixable, ) } } 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 2a5e18b081..81e6a71ab1 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,6 +11,7 @@ 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 @@ -144,11 +145,11 @@ class KeyMapListViewModel( ), ) - private val fixErrorsKeyMapUid = MutableStateFlow(null) + private val fixErrorsKeyMapUid = MutableStateFlow(null) val fixErrorsDialogState: StateFlow = - fixErrorsKeyMapUid.flatMapLatest { uid -> - if (uid == null) { + fixErrorsKeyMapUid.flatMapLatest { dialogId -> + if (dialogId == null) { flowOf(null) } else { combine( @@ -157,17 +158,31 @@ class KeyMapListViewModel( listKeyMaps.actionErrorSnapshot, listKeyMaps.constraintErrorSnapshot, ) { keyMapGroup, triggerSnapshot, actionSnapshot, constraintSnapshot -> - val keyMap = keyMapGroup.keyMaps.dataOrNull()?.firstOrNull { it.uid == uid } - val errors = keyMap - ?.let { - buildKeyMapErrors( - it, - triggerSnapshot, - actionSnapshot, + + val errors: List = when (dialogId) { + is FixErrorsDialogId.Group -> { + buildGroupConstraintsErrors( + keyMapGroup.group?.constraintState?.constraints.orEmpty(), constraintSnapshot, ) } - .orEmpty() + + 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) } } @@ -559,6 +574,17 @@ class KeyMapListViewModel( 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) @@ -633,7 +659,15 @@ class KeyMapListViewModel( } fun onFixClick(keyMapUid: String) { - fixErrorsKeyMapUid.value = keyMapUid + fixErrorsKeyMapUid.value = FixErrorsDialogId.KeyMap(keyMapUid) + } + + fun onFixGroupConstraintsClick() { + val groupUid = keyMapGroupStateFlow.value.group?.uid + + if (groupUid != null) { + fixErrorsKeyMapUid.value = FixErrorsDialogId.Group(groupUid) + } } fun onDismissFixErrorsDialog() { 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 a7fa0dfc1f..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 @@ -34,6 +34,7 @@ fun CompactChip( borderStroke: BorderStroke? = null, onClick: (() -> Unit)? = null, enabled: Boolean = false, + actionContent: (@Composable () -> Unit)? = null, ) { CompositionLocalProvider( LocalMinimumInteractiveComponentSize provides 16.dp, @@ -46,7 +47,7 @@ fun CompactChip( border = borderStroke, shape = AssistChipDefaults.shape, ) { - CompactChipContent(icon, text, contentColor) + CompactChipContent(icon, text, contentColor, actionContent = actionContent) } } else { Surface( @@ -57,16 +58,27 @@ fun CompactChip( 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(), @@ -77,11 +89,17 @@ fun ErrorCompactChip(onClick: () -> Unit, text: String, enabled: Boolean) { }, 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, @@ -98,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/res/values/strings.xml b/base/src/main/res/values/strings.xml index f6f07a6a1d..0405d53281 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1828,7 +1828,7 @@ Group constraints New constraint Delete group constraint - Invert constraint + Tap a constraint to invert it This group From fc64a0cfd7d466d2adf90e585f4cede013b0f1b4 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 20 Sep 2026 23:42:20 +0200 Subject: [PATCH 086/101] #2262 fix: request local network access permission for HTTP request action on Android 17+ --- CHANGELOG.md | 1 + .../io/github/sds100/keymapper/base/actions/ActionUtils.kt | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4bc577369..f167810e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - 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+. ## [4.4.0](https://github.com/sds100/KeyMapper/releases/tag/v4.4.0) 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 b7194a26d1..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 @@ -974,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() } From ca4e12daa558de6f08c9b4e7a405f9976563767f Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 21 Sep 2026 09:53:03 +0200 Subject: [PATCH 087/101] #2261 fix: 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 --- CHANGELOG.md | 1 + .../keymapper/base/actions/ActionData.kt | 53 +++++-- .../base/actions/ActionDataEntityMapper.kt | 46 ++++-- .../base/actions/ActionErrorSnapshot.kt | 14 +- .../keymapper/base/actions/ActionUiHelper.kt | 135 +++++++----------- .../base/actions/CreateActionDelegate.kt | 49 +++++-- .../actions/StepMediaActionBottomSheet.kt | 1 + .../constraints/ChooseConstraintViewModel.kt | 8 +- .../ConfigNotificationConstraintViewModel.kt | 9 +- .../keymapper/base/constraints/Constraint.kt | 9 +- .../constraints/ConstraintEntityMapper.kt | 28 +++- .../constraints/ConstraintErrorSnapshot.kt | 24 +++- .../base/constraints/ConstraintUiHelper.kt | 54 ++++--- .../base/system/apps/ChooseAppFragment.kt | 5 +- .../base/system/apps/ChooseAppResult.kt | 6 + .../system/apps/ChooseAppShortcutResult.kt | 1 + .../system/apps/ChooseAppShortcutViewModel.kt | 5 + .../base/system/apps/ChooseAppViewModel.kt | 5 +- .../sds100/keymapper/base/utils/ErrorUtils.kt | 15 +- .../base/utils/navigation/NavDestination.kt | 3 +- .../utils/navigation/NavigationProvider.kt | 4 +- base/src/main/res/values/strings.xml | 1 + .../actions/ActionDataEntityMapperTest.kt | 60 ++++++++ .../base/actions/ActionUiHelperTest.kt | 62 ++++++++ .../constraints/ConstraintEntityMapperTest.kt | 35 +++++ .../ConstraintErrorSnapshotTest.kt | 14 ++ .../constraints/ConstraintUiHelperTest.kt | 68 +++++++++ .../sds100/keymapper/common/utils/KMResult.kt | 4 +- .../keymapper/data/entities/ActionEntity.kt | 1 + .../data/entities/ConstraintEntity.kt | 1 + 30 files changed, 534 insertions(+), 187 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppResult.kt create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/actions/ActionUiHelperTest.kt create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelperTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f167810e83..9b2592977e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - #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. ## [4.4.0](https://github.com/sds100/KeyMapper/releases/tag/v4.4.0) 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 e268c7bba0..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 @@ -24,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) { @@ -34,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) { @@ -337,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( @@ -350,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 } @@ -393,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 } @@ -401,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 } 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 4820983d24..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 @@ -79,7 +79,10 @@ object ActionDataEntityMapper { } 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 = @@ -93,6 +96,7 @@ object ActionDataEntityMapper { packageName = packageName, shortcutTitle = shortcutTitle, uri = entity.data, + savedAppName = entity.extras.getData(ActionEntity.EXTRA_APP_NAME).valueOrNull(), ) } @@ -451,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") } @@ -1175,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() @@ -1209,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 { @@ -1216,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 { 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/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index 7ca683fe8a..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 @@ -24,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 @@ -42,11 +43,13 @@ class ActionUiHelper( } 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 @@ -214,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) { 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 3c4ba4df9c..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 @@ -423,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}") } @@ -477,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), @@ -485,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") } @@ -517,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), @@ -531,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(), ) @@ -795,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 -> { @@ -814,6 +832,7 @@ class CreateActionDelegate( appShortcutResult.packageName, appShortcutResult.shortcutName, appShortcutResult.uri, + appShortcutResult.appName, ) } 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 34a683af92..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 @@ -40,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 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 041ac9694b..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 @@ -587,7 +587,7 @@ class ChooseConstraintViewModel @Inject constructor( } private suspend fun onSelectAppConstraint(type: ConstraintId) { - val packageName = + val result = navigate( "choose_package_for_constraint", NavDestination.ChooseApp(allowHiddenApps = true), @@ -596,11 +596,13 @@ class ChooseConstraintViewModel @Inject constructor( val constraintData = when (type) { ConstraintId.APP_IN_FOREGROUND -> ConstraintData.AppInForeground( - packageName = packageName, + packageName = result.packageName, + appName = result.appName, ) ConstraintId.APP_PLAYING_MEDIA -> ConstraintData.AppPlayingMedia( - packageName = packageName, + packageName = result.packageName, + appName = result.appName, ) else -> throw IllegalArgumentException( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintViewModel.kt index aa694c2772..46fe472911 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintViewModel.kt @@ -72,12 +72,12 @@ class ConfigNotificationConstraintViewModel @Inject constructor( fun onChooseAppClick() { viewModelScope.launch { - val packageName = navigate( + val result = navigate( "choose_app_for_notification_constraint", NavDestination.ChooseApp(allowHiddenApps = true), ) ?: return@launch - value = packageName + value = result.packageName } } @@ -102,7 +102,10 @@ class ConfigNotificationConstraintViewModel @Inject constructor( return when (selectedField) { NotificationField.PACKAGE -> - ConstraintData.NotificationPosted.FromApp(packageName = value) + ConstraintData.NotificationPosted.FromApp( + packageName = value, + appName = selectedAppName!!, + ) NotificationField.TITLE -> ConstraintData.NotificationPosted.Title( text = value, 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 9a934edb9a..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 @@ -12,12 +12,14 @@ 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 AppPlayingMedia(val packageName: String) : ConstraintData() { + data class AppPlayingMedia(val packageName: String, val appName: String? = null) : + ConstraintData() { override val id: ConstraintId = ConstraintId.APP_PLAYING_MEDIA } @@ -183,7 +185,8 @@ sealed class ConstraintData { TextMatch() @Serializable - data class FromApp(val packageName: String) : NotificationPosted() + data class FromApp(val packageName: String, val appName: String? = null) : + NotificationPosted() } @Serializable diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt index 6eee7f3bb1..857de3fd2e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt @@ -43,6 +43,9 @@ object ConstraintEntityMapper { 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()!! @@ -82,11 +85,11 @@ object ConstraintEntityMapper { val constraintData = when (entity.type) { ConstraintEntity.APP_FOREGROUND, ConstraintEntity.APP_NOT_FOREGROUND, - -> ConstraintData.AppInForeground(getPackageName()) + -> ConstraintData.AppInForeground(getPackageName(), getAppName()) ConstraintEntity.APP_PLAYING_MEDIA, ConstraintEntity.APP_NOT_PLAYING_MEDIA, - -> ConstraintData.AppPlayingMedia(getPackageName()) + -> ConstraintData.AppPlayingMedia(getPackageName(), getAppName()) ConstraintEntity.MEDIA_PLAYING, ConstraintEntity.NO_MEDIA_PLAYING, @@ -209,7 +212,10 @@ object ConstraintEntityMapper { ) ) { NotificationField.PACKAGE -> - ConstraintData.NotificationPosted.FromApp(packageName = value) + ConstraintData.NotificationPosted.FromApp( + packageName = value, + appName = getAppName(), + ) NotificationField.TITLE -> ConstraintData.NotificationPosted.Title( text = value, @@ -266,22 +272,28 @@ object ConstraintEntityMapper { is ConstraintData.AppInForeground -> ConstraintEntity( uid = constraint.uid, type = ConstraintEntity.APP_FOREGROUND, - extras = listOf( + 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 = listOf( + extras = listOfNotNull( EntityExtra( ConstraintEntity.EXTRA_PACKAGE_NAME, constraint.data.packageName, ), + constraint.data.appName?.let { + EntityExtra(ConstraintEntity.EXTRA_APP_NAME, it) + }, ), ) @@ -508,6 +520,12 @@ object ConstraintEntityMapper { ), ) } + + if (constraint.data is ConstraintData.NotificationPosted.FromApp) { + constraint.data.appName?.let { + add(EntityExtra(ConstraintEntity.EXTRA_APP_NAME, it)) + } + } }, ) } 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 8aaacf54ec..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 @@ -37,14 +37,22 @@ class LazyConstraintErrorSnapshot( override fun getError(constraint: Constraint): KMError? { when (constraint.data) { is ConstraintData.AppInForeground -> - return getAppError(constraint.data.packageName, constraint.isNot) + 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, constraint.isNot) + return getAppError( + constraint.data.packageName, + constraint.data.appName, + constraint.isNot, + ) } ConstraintData.MediaPlaying -> { @@ -107,7 +115,11 @@ class LazyConstraintErrorSnapshot( } if (constraint.data is ConstraintData.NotificationPosted.FromApp) { - return getAppError(constraint.data.packageName, constraint.isNot) + return getAppError( + constraint.data.packageName, + constraint.data.appName, + constraint.isNot, + ) } } @@ -117,7 +129,7 @@ class LazyConstraintErrorSnapshot( return null } - private fun getAppError(packageName: String, isNot: Boolean): 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. @@ -127,12 +139,12 @@ class LazyConstraintErrorSnapshot( 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/ConstraintUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt index 2d9d4c72b9..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 @@ -32,17 +32,21 @@ class ConstraintUiHelper( } private fun getNormalTitle(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) }, - ) + 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.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) @@ -161,19 +165,21 @@ class ConstraintUiHelper( } private fun getNotTitle(constraint: Constraint): String = when (constraint.data) { - is ConstraintData.AppInForeground -> - getAppName(constraint.data.packageName).handle( - onSuccess = { getString(R.string.constraint_app_not_foreground_description, it) }, - onError = { getString(R.string.constraint_choose_app_not_foreground) }, - ) + 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_not_playing_media_description, it) - }, - onError = { getString(R.string.constraint_choose_app_not_playing_media) }, - ) + 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) @@ -314,7 +320,9 @@ class ConstraintUiHelper( isNot: Boolean, ): String = when (data) { is ConstraintData.NotificationPosted.FromApp -> { - val appName = getAppName(data.packageName).valueIfFailure { data.packageName } + val appName = getAppName(data.packageName).valueIfFailure { + data.appName ?: data.packageName + } getString( if (isNot) { 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/utils/ErrorUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt index 01edd5c534..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 @@ -86,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 -> 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 f494fb6351..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 @@ -63,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 } 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/res/values/strings.xml b/base/src/main/res/values/strings.xml index 0405d53281..ca5400a571 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -873,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. 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/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/constraints/ConstraintEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapperTest.kt index 3e2f0c742b..b3fdbad8ac 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapperTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapperTest.kt @@ -313,6 +313,41 @@ class ConstraintEntityMapperTest { } } + @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), diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshotTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshotTest.kt index caa4253a6a..6df3726b64 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshotTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshotTest.kt @@ -100,4 +100,18 @@ class ConstraintErrorSnapshotTest { `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/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/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/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 2687b63959..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" 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 984352b37d..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 @@ -148,6 +148,7 @@ data class ConstraintEntity( 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" From a9e02dab79367e8ac5e5e84579748b365ca598b2 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 21 Sep 2026 09:54:59 +0200 Subject: [PATCH 088/101] use a fork of setup-android github action plugin --- .github/workflows/pull-request.yml | 5 ++++- .github/workflows/testing.yml | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index c3d0cecbad..74287a040e 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -109,7 +109,10 @@ jobs: cache: 'gradle' - name: Setup Android SDK - uses: android-actions/setup-android@v2 + # Pinned to vbuberen's fork/commit for android-actions/setup-android#538, + # which drops the deprecated `tools` package that Google stopped serving. + # Switch back to android-actions/setup-android@v4 once that PR merges. + uses: vbuberen/setup-android@dd83ef4e10ea64da1c6e9484424382da8db64e36 with: ndk-version: "27.2.12479018" diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index ab8c9d4025..6bcd51dd57 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -121,7 +121,10 @@ jobs: cache: 'gradle' - name: Setup Android SDK - uses: android-actions/setup-android@v2 + # Pinned to vbuberen's fork/commit for android-actions/setup-android#538, + # which drops the deprecated `tools` package that Google stopped serving. + # Switch back to android-actions/setup-android@v4 once that PR merges. + uses: vbuberen/setup-android@dd83ef4e10ea64da1c6e9484424382da8db64e36 with: ndk-version: "27.2.12479018" From 4d0412fd2b4e6a45ff1d924986475725e0a8609a Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 21 Sep 2026 09:56:58 +0200 Subject: [PATCH 089/101] fix: use different colors for group constraint mode switcher --- .../keymapper/base/constraints/ConstraintModeButtons.kt | 3 ++- .../github/sds100/keymapper/base/home/KeyMapListHeader.kt | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) 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 index a40ece261d..821f4898d3 100644 --- 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 @@ -9,6 +9,7 @@ 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 @@ -39,7 +40,7 @@ fun ConstraintModeButtons( ) } -@Preview +@PreviewLightDark @Composable private fun Preview() { KeyMapperTheme { 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 6438e4c679..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 @@ -33,6 +33,7 @@ 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 @@ -224,8 +225,8 @@ private fun ChildGroupHeader( val modeColors = SegmentedButtonDefaults.colors( activeContainerColor = MaterialTheme.colorScheme.primary, activeContentColor = MaterialTheme.colorScheme.onPrimary, - activeBorderColor = MaterialTheme.colorScheme.onPrimaryContainer, - inactiveBorderColor = MaterialTheme.colorScheme.onPrimaryContainer, + activeBorderColor = MaterialTheme.colorScheme.primary, + inactiveBorderColor = MaterialTheme.colorScheme.primary, inactiveContentColor = MaterialTheme.colorScheme.onPrimaryContainer, ) @@ -386,7 +387,7 @@ private fun RootGroupHeaderNoWarningsPreview() { } @OptIn(ExperimentalMaterial3Api::class) -@Preview +@PreviewLightDark @Composable private fun ChildGroupHeaderPreview() { val state = KeyMapAppBarState.ChildGroup( From bb8a30bdb9d9f7bfd10e2fe05f7e75af99cb22b7 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 21 Sep 2026 10:13:11 +0200 Subject: [PATCH 090/101] use v4 of setup-android github action --- .github/workflows/pull-request.yml | 5 +---- .github/workflows/testing.yml | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 74287a040e..ecc44a298f 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -109,10 +109,7 @@ jobs: cache: 'gradle' - name: Setup Android SDK - # Pinned to vbuberen's fork/commit for android-actions/setup-android#538, - # which drops the deprecated `tools` package that Google stopped serving. - # Switch back to android-actions/setup-android@v4 once that PR merges. - uses: vbuberen/setup-android@dd83ef4e10ea64da1c6e9484424382da8db64e36 + 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 6bcd51dd57..1411978779 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -121,10 +121,7 @@ jobs: cache: 'gradle' - name: Setup Android SDK - # Pinned to vbuberen's fork/commit for android-actions/setup-android#538, - # which drops the deprecated `tools` package that Google stopped serving. - # Switch back to android-actions/setup-android@v4 once that PR merges. - uses: vbuberen/setup-android@dd83ef4e10ea64da1c6e9484424382da8db64e36 + uses: android-actions/setup-android@v4 with: ndk-version: "27.2.12479018" From af6a38992a056e6648e105a74a23849bafdfb35d Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 21 Sep 2026 10:15:24 +0200 Subject: [PATCH 091/101] use v5 of setup-java github action --- .github/workflows/pull-request.yml | 2 +- .github/workflows/testing.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index ecc44a298f..caccddbd8f 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -102,7 +102,7 @@ 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 diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 1411978779..0ac8ac3b14 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -114,7 +114,7 @@ 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 From 340c9bfcd05ebf58bb0a794f6c2bacb694a0c6d3 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 21 Sep 2026 10:54:27 +0200 Subject: [PATCH 092/101] fix: show constraint groups in key map list item --- .../constraints/ConfigConstraintsUseCase.kt | 2 +- .../constraints/ConfigConstraintsViewModel.kt | 4 +- .../base/constraints/ConstraintState.kt | 2 +- .../base/detection/DetectKeyMapsUseCase.kt | 2 +- .../base/detection/SimpleMappingController.kt | 2 +- .../base/home/KeyMapListItemCreator.kt | 114 +++++++++++++++--- .../base/home/KeyMapListViewModel.kt | 20 +-- .../keymapper/base/home/ListKeyMapsUseCase.kt | 4 +- .../CreateKeyMapShortcutViewModel.kt | 4 +- .../KeyMapConstraintsComparator.kt | 32 ++++- .../base/utils/ui/ResourceProvider.kt | 7 ++ .../ConstraintStateEntityMapperTest.kt | 2 +- 12 files changed, 156 insertions(+), 39 deletions(-) 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 56f2296b81..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 @@ -37,7 +37,7 @@ 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) } 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 3e0847eaa5..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 @@ -146,7 +146,7 @@ class ConfigConstraintsViewModel @Inject constructor( .firstOrNull() ?.dataOrNull() ?.constraintState - ?.constraints + ?.allConstraints ?.find { it.uid == constraintUid } ?: return@launch @@ -220,7 +220,7 @@ class ConfigConstraintsViewModel @Inject constructor( errorSnapshot: ConstraintErrorSnapshot, expandedGroups: Set, ): ConfigConstraintsState { - if (state.constraints.isEmpty()) { + if (state.allConstraints.isEmpty()) { return ConfigConstraintsState.Empty(shortcuts) } 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 f1642c3e04..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 @@ -10,6 +10,6 @@ data class ConstraintState( val groups: List = emptyList(), val mode: ConstraintMode = ConstraintMode.AND, ) { - val constraints: List + val allConstraints: List get() = groups.flatMap { it.constraints } } 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 f908f05a21..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 @@ -100,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) } 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 8b348e60a2..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 @@ -59,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 } 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 f39a03e5a1..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 @@ -79,7 +82,7 @@ class KeyMapListItemCreator( val options = getTriggerOptionLabels(keyMap.trigger) val actionChipList = getActionChipList(keyMap, showDeviceDescriptors, actionErrorSnapshot) - val constraintChipList = + val (constraintChipList, constraintMode) = buildConstraintChipList( keyMap.constraintState, constraintErrorSnapshot, @@ -100,7 +103,7 @@ class KeyMapListItemCreator( triggerSeparatorIcon = triggerSeparator, actions = actionChipList, constraints = constraintChipList, - constraintMode = keyMap.constraintState.mode, + constraintMode = constraintMode, options = options, isEnabled = keyMap.isEnabled, hasError = hasError, @@ -177,32 +180,113 @@ class KeyMapListItemCreator( constraintState: ConstraintState, errorSnapshot: ConstraintErrorSnapshot, isEnabled: Boolean, - ): 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) + ): Pair, ConstraintMode> { + if (constraintState.groups.isEmpty()) { + return Pair(emptyList(), constraintState.mode) + } + + 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) } + + return Pair(chips, firstGroup.mode) + } else { + val chips = + constraintState.groups.mapNotNull { + buildGroupConstraintChip( + it, + errorSnapshot, + isEnabled, + ) + } + + return Pair(chips, constraintState.mode) + } + } + + 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 + } + + private fun buildGroupConstraintChip( + group: ConstraintGroup, + errorSnapshot: ConstraintErrorSnapshot, + isEnabled: Boolean, + ): ComposeChipModel? { + if (group.constraints.isEmpty()) { + return null + } + + 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 + } + + val icon: ComposeIconInfo? = null + val error: KMError? = + group.constraints.firstNotNullOfOrNull { errorSnapshot.getError(it) } // 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, + id = group.uid, text = text, icon = icon, isEnabled = isEnabled, ) } else { ComposeChipModel.Error( - constraint.uid, - text, - error, - error.isFixable, + id = group.uid, + text = text, + error = error, + isFixable = error.isFixable, ) } - - yield(chip) + return chip } - }.toList() + } private fun floatingButtonKeyName(key: FloatingButtonKey): String = buildString { when (key.clickType) { 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 81e6a71ab1..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 @@ -162,7 +162,7 @@ class KeyMapListViewModel( val errors: List = when (dialogId) { is FixErrorsDialogId.Group -> { buildGroupConstraintsErrors( - keyMapGroup.group?.constraintState?.constraints.orEmpty(), + keyMapGroup.group?.constraintState?.allConstraints.orEmpty(), constraintSnapshot, ) } @@ -492,16 +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, - isEnabled = true, - ), - 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, @@ -515,7 +517,7 @@ class KeyMapListViewModel( private fun buildGroupListItem(groupState: GroupWithState): GroupListItemModel { var icon: ComposeIconInfo? = null - val constraint = groupState.group.constraintState.constraints.firstOrNull() + val constraint = groupState.group.constraintState.allConstraints.firstOrNull() if (constraint != null) { icon = constraintUiHelper.getIcon(constraint) } 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 cfb20d2c4a..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 @@ -139,7 +139,7 @@ class ListKeyMapsUseCaseImpl @Inject constructor( ): GroupWithState { val isEnabled = keyMaps.isEmpty() || keyMaps.any { it.isEnabled } - val hasOwnConstraintError = group.constraintState.constraints.any { + val hasOwnConstraintError = group.constraintState.allConstraints.any { constraintErrorSnapshot.getError(it) != null } @@ -149,7 +149,7 @@ class ListKeyMapsUseCaseImpl @Inject constructor( actionErrorSnapshot.getErrors( keyMap.actionList.filter { it.isEnabled }.map { it.data }, ).values.any { it != null } || - keyMap.constraintState.constraints.any { + keyMap.constraintState.allConstraints.any { constraintErrorSnapshot.getError(it) != null } ) 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 550a6dd58e..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 @@ -121,7 +121,7 @@ class CreateKeyMapShortcutViewModel @Inject constructor( 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) } @@ -157,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, ) 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 5e6b58f269..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,16 +85,25 @@ class KeyMapConstraintsComparator( is ConstraintData.AppInForeground -> displayConstraints.getAppName( constraint.data.packageName, ) + is ConstraintData.AppPlayingMedia -> displayConstraints.getAppName( constraint.data.packageName, ) + is ConstraintData.BtDeviceConnected -> Success(constraint.data.deviceName) + is ConstraintData.Charging -> Success("") + is ConstraintData.DeviceIsLocked -> Success("") + is ConstraintData.FlashlightOn -> Success(constraint.data.lens.toString()) + is ConstraintData.ImeChosen -> Success(constraint.data.imeLabel) + is ConstraintData.InPhoneCall -> Success("") + is ConstraintData.MediaPlaying -> Success("") + is ConstraintData.NotificationPosted -> Success( when (constraint.data) { is ConstraintData.NotificationPosted.FromApp -> @@ -104,16 +113,25 @@ class KeyMapConstraintsComparator( 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.ScreenOn -> Success("") + is ConstraintData.DisplayResolution -> Success( "${constraint.data.width}x${constraint.data.height}", ) + is ConstraintData.WifiConnected -> if (constraint.data.ssid == null) { Success("") } else { @@ -121,7 +139,9 @@ class KeyMapConstraintsComparator( } is ConstraintData.WifiOn -> Success("") + is ConstraintData.LockScreenShowing -> Success("") + is ConstraintData.Time -> Success( constraint.data.startTime .toEpochSecond(LocalDate.now(), ZoneOffset.UTC) @@ -129,11 +149,15 @@ class KeyMapConstraintsComparator( ) ConstraintData.HingeClosed -> Success("") + ConstraintData.HingeOpen -> Success("") + ConstraintData.KeyboardShowing -> Success("") + is ConstraintData.PhysicalOrientation -> Success( constraint.data.physicalOrientation.toString(), ) + ConstraintData.NotificationPanelShowing -> Success("") } } 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/test/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapperTest.kt index 6fb68da1c1..692a7dcebd 100644 --- 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 @@ -46,7 +46,7 @@ class ConstraintStateEntityMapperTest { assertThat(state.groups.size, `is`(1)) assertThat( - state.constraints, + state.allConstraints, `is`( listOf( Constraint(uid = "uid1", data = ConstraintData.ScreenOn, isNot = true), From 8bbb689ba161dcf801611b8924d1cadef2dcc0f9 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 21 Sep 2026 10:59:00 +0200 Subject: [PATCH 093/101] fix tests --- .../sds100/keymapper/base/utils/ui/FakeResourceProvider.kt | 4 ++++ 1 file changed, 4 insertions(+) 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] ?: "" } From 27e5496e6598531f79c5c55881150d9b96c85ecb Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 21 Sep 2026 11:15:56 +0200 Subject: [PATCH 094/101] use filled icon for whats new button in app bar --- .../io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 9be7a67ed5..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 @@ -30,8 +30,8 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Sort -import androidx.compose.material.icons.outlined.Campaign 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 @@ -421,7 +421,7 @@ private fun AppBarActions( if (showWhatsNew) { IconButton(onClick = onWhatsNewClick) { Icon( - Icons.Outlined.Campaign, + Icons.Rounded.Campaign, contentDescription = stringResource(R.string.home_app_bar_whats_new), ) } From 184a484aa069c57e1cd11af19b5e85076f36f45f Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 21 Sep 2026 11:17:04 +0200 Subject: [PATCH 095/101] use less padding in whats new dialog --- .../java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt index 6e327ab851..791d34aac3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt @@ -74,7 +74,7 @@ fun WhatsNewDialogContent( Column( modifier = modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { WhatsNewHeader( modifier = Modifier.padding(horizontal = 24.dp), From 20e942a0d74bb29291830664a7216711648abce8 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 21 Sep 2026 11:17:31 +0200 Subject: [PATCH 096/101] write whats new --- base/src/main/assets/whats-new-app.txt | 15 +++++++-------- base/src/main/assets/whats-new.txt | 13 +++++-------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/base/src/main/assets/whats-new-app.txt b/base/src/main/assets/whats-new-app.txt index 5e8434e331..6733687866 100644 --- a/base/src/main/assets/whats-new-app.txt +++ b/base/src/main/assets/whats-new-app.txt @@ -1,8 +1,7 @@ -+ Cycle keyboard language action | Switch between your keyboard's languages with a key press -+ Sort key maps by enabled/disabled -+ Custom seek amount for media step actions -+ Export/import key maps on Android TV -+ "Reduce app killing" tips for Xiaomi/Redmi/Poco devices -* Screen tap/swipe/pinch actions now scale to display resolution -- Fix some crashes when auto starting Expert Mode -- Many other bug fixes and improvements +! 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 From d1ff474d4849997469b9c6fb6529526147be9c95 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 21 Sep 2026 11:19:58 +0200 Subject: [PATCH 097/101] add more padding to spotlight cards in WhatsNewDialog --- .../io/github/sds100/keymapper/base/home/WhatsNewDialog.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt index 791d34aac3..756cc3e2fd 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt @@ -178,7 +178,6 @@ private fun WhatsNewSection( if (item.isSpotlight) { Surface( modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.surfaceContainerHighest, border = BorderStroke( width = 1.dp, @@ -186,7 +185,7 @@ private fun WhatsNewSection( ), shape = MaterialTheme.shapes.small, ) { - WhatsNewItemText(Modifier.padding(vertical = 4.dp, horizontal = 8.dp), item) + WhatsNewItemText(Modifier.padding(vertical = 8.dp, horizontal = 12.dp), item) } } else { WhatsNewItemText( From ac23895ee983bb17b51f547ad2d9e838099afe78 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 21 Sep 2026 11:21:52 +0200 Subject: [PATCH 098/101] gitignore .kotlin directory properly --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 00ac8bc3da..7da1df27a3 100644 --- a/.gitignore +++ b/.gitignore @@ -98,4 +98,4 @@ app/.env evdev/build evdev/.cxx evdev/src/main/rust/*/target -*/.kotlin \ No newline at end of file +.kotlin/* \ No newline at end of file From 5dce21e19ce83164886ce7ae30884a5e116091de Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 21 Sep 2026 12:12:13 +0200 Subject: [PATCH 099/101] set release date for 4.5.0 --- CHANGELOG.md | 2 +- app/version.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b2592977e..92891533ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [4.5.0](https://github.com/sds100/KeyMapper/releases/tag/v4.5.0) -#### TO BE RELEASED +#### 21 September 2026 ## Added diff --git a/app/version.properties b/app/version.properties index 3c584b5c89..bb46b71a18 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ VERSION_NAME=4.5.0 -VERSION_CODE=269 +VERSION_CODE=270 From 063694ef190f3ed9fab1c16b13f889859a74ffe4 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 21 Sep 2026 12:19:30 +0200 Subject: [PATCH 100/101] #2244 fix: 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. --- CHANGELOG.md | 1 + .../expertmode/SystemBridgeAutoStarter.kt | 18 ++++++++++++ .../notifications/NotificationController.kt | 28 ------------------- 3 files changed, 19 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92891533ea..9ad1fcbbdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - #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) 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/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) - } } From e237a8ddd153990287e172e1c8780aca4a0ee225 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 21 Sep 2026 13:04:48 +0200 Subject: [PATCH 101/101] bump version code --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index bb46b71a18..7a9b55b422 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ VERSION_NAME=4.5.0 -VERSION_CODE=270 +VERSION_CODE=271